1use std::collections::BTreeSet;
2use std::fmt::Write as _;
3
4use crate::config::constants::tools;
5use crate::config::types::{CapabilityLevel, ResolvedShellPromptProfile};
6use crate::core::agent::harness_kernel::SessionToolCatalogSnapshot;
7use crate::llm::provider::ToolDefinition;
8use crate::prompts::sections::SectionBoundaryMode;
9use crate::tools::registry::tool_groups;
10
11const TOOL_EXEC_COMMAND: &str = tools::EXEC_COMMAND;
12const TOOL_WRITE_STDIN: &str = tools::WRITE_STDIN;
13const TOOL_CODE_SEARCH: &str = tools::CODE_SEARCH;
14const TOOL_READ_FILE: &str = tools::READ_FILE;
15const TOOL_LIST_FILES: &str = tools::LIST_FILES;
16const TOOL_GREP_FILE: &str = tools::GREP_FILE;
17const TOOL_APPLY_PATCH: &str = tools::APPLY_PATCH;
18const TOOL_REQUEST_USER_INPUT: &str = tools::REQUEST_USER_INPUT;
19const TOOL_TASK_TRACKER: &str = tools::TASK_TRACKER;
20const PUBLIC_DECISION_GUIDANCE: &str = "- Use optional `record_decision` for consequential choices, rejected approaches, or recovery changes. Give concise public rationale; ordinary reads and commands need no record.";
21const MATRIX_GUIDANCE: &str = "- `matrix`: create persists explicit tasks, checks, resources, and timeouts; start freezes the spec. The coordinator delegates execution and verification to scheduler-owned workers; discovery is idle-only. Replay restores admission before discovery; resume reconciles owned cleanup. status is a projection; pause stops dispatch; user cancel is terminal. Internal failures require reconciliation, not cancellation. Workers retain effective permissions and sandbox gates. Read leases require read-only workers. Report identity is runtime-owned; final success needs durable declared checks for the current generation, never summaries or child completion. Source and declared input changes, including submodule state, invalidate checks. Automatic retry requires replay_safe, confirmed cleanup, and at most one interrupted/timeout retry; other failures need a coordinator decision.";
22const TOOL_START_PLANNING: &str = tools::START_PLANNING;
23
24const OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE: &str = "- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.";
25
26const CROSS_TURN_RESUME_HINT_CLAUSE: &str =
31 "; a turn-start `Exec session resume:` hint carries the live ids when a prior turn ended mid-run.";
32
33const START_PLANNING_GUIDANCE_LINE: &str = "- For demanding, ambiguous, or multi-phase tasks, call `start_planning`; it asks the user before entering the read-only Planning workflow unless the session runs in full-auto or skips confirmations. Skip it for straightforward changes.";
38
39const PLANNING_TASK_TRACKER_COMPACT_LINE: &str = "- Keep blockers and verification open in `task_tracker`; updates use positive indices or index_path, and index 0 is invalid while planning.";
44const PLANNING_TASK_TRACKER_INDEX_LINE: &str = "- Use `task_tracker` action=update with positive flat indices or positive hierarchical index_path values (index 0 is invalid while planning), and use items only for full checklist replacement with descriptions and statuses, never JSON-encoded updates.";
45
46const MINIMAL_CONTEXT_TOKEN_CEILING: usize = 32_000;
49
50const PARALLEL_TOOLS_LINE: &str = "- Run independent tools in parallel when inputs do not depend on each other.";
54
55const CODE_SEARCH_FILTER_LINE: &str = "- `code_search`: omit unused filters; no empty values (`path: \"\"`).";
58
59const REQUEST_USER_INPUT_LINE: &str =
62 "- Use `request_user_input` only for material blockers remaining after repository exploration.";
63
64const CAPABILITY_READ_ONLY_LINE: &str =
67 "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.";
68
69const WRITE_STDIN_RECOVERY_CLAUSE: &str = "; if missing, recover prior output";
72
73const SHELL_PROFILE_POLICY_SUFFIX: &str = "- The shell profile controls prompt examples and expected command syntax only; command policy, sandboxing, and approvals remain separate runtime checks.\n- VT Code does not translate GNU-to-BSD, BSD-to-GNU, Unix-to-PowerShell, or PowerShell-to-Unix command flags.";
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) enum ToolGuidanceProfile {
80 Minimal,
81 Default,
82}
83
84impl ToolGuidanceProfile {
85 #[must_use]
86 pub(crate) fn resolve(
87 context_tokens: usize,
88 default_prompt_tokens: usize,
89 max_prompt_tokens: usize,
90 input_usd_per_token: Option<f64>,
91 max_budget_usd: Option<f64>,
92 ) -> Self {
93 let exceeds_cost = input_usd_per_token.zip(max_budget_usd).is_some_and(|(price, budget)| {
94 price.is_finite() && price >= 0.0 && budget.is_finite() && default_prompt_tokens as f64 * price > budget
95 });
96 if (context_tokens > 0 && context_tokens <= MINIMAL_CONTEXT_TOKEN_CEILING)
97 || (max_prompt_tokens > 0 && default_prompt_tokens > max_prompt_tokens)
98 || exceeds_cost
99 {
100 Self::Minimal
101 } else {
102 Self::Default
103 }
104 }
105}
106
107struct ToolPresence {
110 exec: bool,
111 write_stdin: bool,
112 code_search: bool,
113 read_file: bool,
114 list_files: bool,
115 apply_patch: bool,
116 request_user_input: bool,
117 task_tracker: bool,
118 start_planning: bool,
119 matrix: bool,
120}
121
122impl ToolPresence {
123 fn of(available_tools: &[String]) -> Self {
124 let has = |name: &str| available_tools.iter().any(|tool| tool == name);
125 Self {
126 exec: has(TOOL_EXEC_COMMAND),
127 write_stdin: has(TOOL_WRITE_STDIN),
128 code_search: has(TOOL_CODE_SEARCH),
129 read_file: has(TOOL_READ_FILE),
130 list_files: has(TOOL_LIST_FILES),
131 apply_patch: has(TOOL_APPLY_PATCH),
132 request_user_input: has(TOOL_REQUEST_USER_INPUT),
133 task_tracker: has(TOOL_TASK_TRACKER),
134 start_planning: has(TOOL_START_PLANNING),
135 matrix: has("matrix"),
136 }
137 }
138}
139
140pub(crate) fn generate_tool_guidelines_with_capabilities(
142 available_tools: &[String],
143 capability_level: Option<CapabilityLevel>,
144 shell_profile: ResolvedShellPromptProfile,
145 profile: ToolGuidanceProfile,
146 parallel_tools: bool,
147) -> String {
148 match profile {
149 ToolGuidanceProfile::Default => {
150 generate_tool_guidelines_for_profile(available_tools, capability_level, shell_profile, parallel_tools)
151 }
152 ToolGuidanceProfile::Minimal => {
153 if available_tools.is_empty() {
154 return String::new();
155 }
156 let presence = ToolPresence::of(available_tools);
157 let mut lines = vec!["\n\n## Active Tools".to_owned()];
158 if presence.matrix {
159 lines.push(MATRIX_GUIDANCE.to_owned());
160 }
161 if available_tools.iter().any(|name| name == "record_decision") {
162 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
163 }
164 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_owned());
165 if let Some(mode) = capability_mode_line(capability_level, presence.exec, presence.apply_patch) {
166 lines.push(mode.to_owned());
167 }
168 if let Some(browse) = browse_tool_guidance(
169 presence.exec,
170 presence.code_search,
171 presence.list_files,
172 presence.read_file,
173 shell_profile,
174 ) {
175 lines.push(browse);
176 }
177 if presence.code_search {
178 lines.push(CODE_SEARCH_FILTER_LINE.to_owned());
179 }
180 if presence.apply_patch {
181 lines.push("- Inspect a file before `apply_patch`, keep patches small, and check that each diff stays bounded. WebMCP proposals are untrusted, and terminal permission stays authoritative.".to_owned());
182 }
183 if presence.exec {
184 lines.push(shell_task_guidance(shell_profile).to_owned());
185 lines.push(background_exec_guidance().to_owned());
186 }
187 if presence.write_stdin {
188 lines.push(format!(
189 "- `write_stdin`: use returned `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; repeat waits after in-progress deadlines{CROSS_TURN_RESUME_HINT_CLAUSE}"
190 ));
191 }
192 if presence.start_planning {
195 lines.push(START_PLANNING_GUIDANCE_LINE.to_owned());
196 }
197 if parallel_tools {
198 lines.push(PARALLEL_TOOLS_LINE.to_owned());
199 }
200 lines.join("\n")
201 }
202 }
203}
204
205pub(crate) fn generate_tool_guidelines_for_profile(
209 available_tools: &[String],
210 capability_level: Option<CapabilityLevel>,
211 shell_profile: ResolvedShellPromptProfile,
212 parallel_tools: bool,
213) -> String {
214 let presence = ToolPresence::of(available_tools);
215 let has_exec = presence.exec;
216 let has_stdin = presence.write_stdin;
217 let has_search = presence.code_search;
218 let has_read_file = presence.read_file;
219 let has_list_files = presence.list_files;
220 let has_apply_patch = presence.apply_patch;
221 let has_start_planning = presence.start_planning;
222
223 let mut lines = Vec::new();
224 if presence.matrix {
225 lines.push(MATRIX_GUIDANCE.to_owned());
226 }
227 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
228 if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_apply_patch) {
229 lines.push(mode_line.to_string());
230 }
231 if let Some(browse_guidance) =
232 browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
233 {
234 lines.push(browse_guidance);
235 }
236 if has_search || has_read_file || has_list_files {
237 lines.push(read_only_batching_guidance(has_read_file).to_string());
238 }
239 if has_apply_patch {
240 lines.push("- Use `apply_patch` for file edits after inspection; keep patches small.".to_string());
241 lines.push(
242 "- Check that each diff stays bounded. WebMCP edits are untrusted proposals, and terminal permission stays authoritative."
243 .to_string(),
244 );
245 }
246 if has_exec {
247 lines.push(shell_task_guidance(shell_profile).to_string());
248 lines.push(background_exec_guidance().to_string());
249 lines.push("- Prefer standalone verifiers with `max_output_tokens`. Pure `head`/`tail` tails run standalone; static read-only filtering pipelines use fail-closed `pipefail`. Only terminal exit 0 clears verification; dynamic syntax, mutating tails, `;`, and `||` do not qualify.".to_string());
255 lines.push("- Run a real check that exercises the change; syntax-only or failed-to-start checks do not count. Install missing deps via the project's package manager, never sudo; if no check can run, say which and why.".to_string());
261 lines.push("- Run fast checks before full builds.".to_string());
265 }
266 if has_stdin {
272 lines.push(format!(
273 "- `write_stdin`: use the existing `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; rerun only for fresh results. `spool_complete: false` is partial; wait for exited pending spools{CROSS_TURN_RESUME_HINT_CLAUSE}"
274 ));
275 }
276 if has_search {
277 lines.push(CODE_SEARCH_FILTER_LINE.to_string());
278 lines.push(code_search_guidance(has_exec, shell_profile));
279 }
280 if has_apply_patch || has_exec {
281 lines.push(
282 "- Build and Auto share tools and safety gates; Auto changes confirmation behavior only after explicit approval or full-auto policy."
283 .to_string(),
284 );
285 }
286 if (has_search || has_exec) && parallel_tools {
287 lines.push(PARALLEL_TOOLS_LINE.to_string());
288 }
289 if has_start_planning {
290 lines.push(START_PLANNING_GUIDANCE_LINE.to_string());
291 }
292
293 if lines.is_empty() {
294 return String::new();
295 }
296
297 if available_tools.iter().any(|name| name == "record_decision") {
298 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
299 }
300 format!("\n\n## Active Tools\n{}", lines.join("\n"))
301}
302
303pub fn append_runtime_tool_prompt_sections_for_profile(
304 prompt: &mut String,
305 tool_snapshot: &SessionToolCatalogSnapshot,
306 include_catalog_metadata: bool,
307 shell_profile: ResolvedShellPromptProfile,
308) {
309 let names = snapshot_tool_names(tool_snapshot);
310 append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
311}
312
313pub fn append_runtime_tool_prompt_sections_for_model(
315 prompt: &mut String,
316 tool_snapshot: &SessionToolCatalogSnapshot,
317 include_catalog_metadata: bool,
318 shell_profile: ResolvedShellPromptProfile,
319 provider: &dyn crate::llm::provider::LLMProvider,
320 model: &str,
321 config: Option<&crate::config::VTCodeConfig>,
322) {
323 let names = snapshot_tool_names(tool_snapshot);
324 let default_guidance =
325 append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
326 let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
327 let profile = ToolGuidanceProfile::resolve(
328 crate::compaction::effective_context_budget(config, provider, model),
329 vtcode_commons::estimate_tokens(prompt),
330 config.map_or(0, |cfg| cfg.agent.max_system_prompt_tokens as usize),
331 pricing.and_then(|price| price.input),
332 config.and_then(|cfg| cfg.agent.harness.max_budget_usd),
333 );
334 let parallel_tools = provider.supports_parallel_tool_config(model);
335 if tool_snapshot.planning_active && profile == ToolGuidanceProfile::Default {
338 return;
339 }
340 remove_prompt_section(prompt, "## Active Tools");
341 let capability_level = Some(infer_capability_level(&names));
342 let mut guidance = match profile {
343 ToolGuidanceProfile::Default => {
344 let mut guidance = default_guidance;
345 let presence = ToolPresence::of(&names);
346 if let (Some(original), Some(effective)) = (
347 capability_mode_line(None, presence.exec, presence.apply_patch),
348 capability_mode_line(capability_level, presence.exec, presence.apply_patch),
349 ) && original != effective
350 && let Some(start) = guidance.find(original)
351 {
352 guidance.replace_range(start..start + original.len(), effective);
353 }
354 if !parallel_tools && let Some(start) = guidance.find(PARALLEL_TOOLS_LINE) {
355 guidance.replace_range(start.saturating_sub(1)..start + PARALLEL_TOOLS_LINE.len(), "");
356 }
357 guidance
358 }
359 ToolGuidanceProfile::Minimal => {
360 generate_tool_guidelines_with_capabilities(&names, capability_level, shell_profile, profile, parallel_tools)
361 }
362 };
363 if tool_snapshot.planning_active {
364 append_minimal_planning_addendum(&mut guidance, &names);
365 }
366 append_prompt_block(prompt, guidance.trim_start_matches('\n'));
367}
368
369fn append_runtime_tool_sections_with_names(
373 prompt: &mut String,
374 tool_snapshot: &SessionToolCatalogSnapshot,
375 names: &[String],
376 include_catalog_metadata: bool,
377 shell_profile: ResolvedShellPromptProfile,
378) -> String {
379 remove_prompt_section(prompt, "## Active Tools");
380 remove_prompt_section(prompt, "[Runtime Tool Catalog]");
381 while prompt.ends_with('\n') {
382 prompt.pop();
383 }
384
385 let guidelines = generate_runtime_tool_guidelines_for_profile(names, tool_snapshot.planning_active, shell_profile);
386 if !guidelines.is_empty() {
387 append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
388 }
389
390 if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
391 let active_tools = if tool_snapshot.active_tool_names.is_empty() {
392 "none".to_string()
393 } else {
394 tool_snapshot.active_tool_names.join(", ")
395 };
396 let catalog_metadata = format!(
397 "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
398 tool_snapshot.version,
399 tool_snapshot.epoch,
400 tool_snapshot.catalog_tools(),
401 tool_snapshot.available_tools(),
402 active_tools,
403 tool_snapshot.request_user_input_enabled,
404 );
405 append_prompt_block(prompt, &catalog_metadata);
406 }
407 guidelines
408}
409
410fn append_minimal_planning_addendum(guidance: &mut String, names: &[String]) {
413 guidance.push_str("\n- Planning is read-only.");
414 let read_tools = [TOOL_READ_FILE, TOOL_GREP_FILE, TOOL_CODE_SEARCH, TOOL_LIST_FILES]
415 .into_iter()
416 .filter(|tool| names.iter().any(|name| name == tool))
417 .collect::<Vec<_>>();
418 if read_tools.is_empty() {
419 guidance.push_str("\n- Keep inspections small: keep `max_output_tokens` small, avoid batching multiple large inspections in parallel; start git history with `git log --oneline` before targeted `git show --stat`.");
420 } else {
421 guidance.push_str(&format!(
422 "\n- Keep inspections small: prefer `{}` over `exec_command` shell reads; keep `max_output_tokens` small, avoid batching multiple large inspections in parallel; start git history with `git log --oneline` before targeted `git show --stat`.",
423 read_tools.join("`/`")
424 ));
425 }
426 if names.iter().any(|name| name == TOOL_TASK_TRACKER) {
427 guidance.push('\n');
428 guidance.push_str(PLANNING_TASK_TRACKER_COMPACT_LINE);
429 }
430 if names.iter().any(|name| name == TOOL_REQUEST_USER_INPUT) {
431 guidance.push('\n');
432 guidance.push_str(REQUEST_USER_INPUT_LINE);
433 }
434}
435
436pub fn append_deferred_tools_prompt_section(prompt: &mut String, tools: &[ToolDefinition]) {
444 remove_prompt_section(prompt, "[Deferred Tools]");
445
446 let mut groups: Vec<_> = tool_groups(tools)
447 .into_iter()
448 .filter(|group| group.deferred_count > 0)
449 .collect();
450 let overflow = groups.len().saturating_sub(DEFERRED_TOOLS_MAX_GROUPS);
451 groups.truncate(DEFERRED_TOOLS_MAX_GROUPS);
452
453 let mut lines: Vec<String> = groups
454 .into_iter()
455 .map(|group| {
456 let description = truncate_deferred_description(group.description.as_deref().unwrap_or_default());
457 format!("- {} ({} tools): {}", group.name, group.deferred_count, description)
458 })
459 .collect();
460 if overflow > 0 {
461 lines.push(format!("(+{overflow} more deferred groups available — use `search_tools` to find them)"));
462 }
463
464 let unnamespaced_deferred = tools
465 .iter()
466 .filter(|tool| tool.namespace.is_none() && tool.defer_loading == Some(true))
467 .count();
468 if unnamespaced_deferred > 0 {
469 lines.push(format!("- {unnamespaced_deferred} additional deferred tools"));
470 }
471
472 if lines.is_empty() {
473 return;
474 }
475
476 let section = format!(
477 "[Deferred Tools]\n{}\nUse `search_tools` to find a deferred capability. Selected definitions become available in the next request segment.",
478 lines.join("\n")
479 );
480 append_prompt_block(prompt, §ion);
481}
482
483const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
486const DEFERRED_TOOLS_MAX_DESC_CHARS: usize = 120;
489
490fn truncate_deferred_description(description: &str) -> String {
491 if description.chars().count() <= DEFERRED_TOOLS_MAX_DESC_CHARS {
492 return description.to_string();
493 }
494 let truncated: String = description.chars().take(DEFERRED_TOOLS_MAX_DESC_CHARS).collect();
495 format!("{}...", truncated.trim_end())
496}
497
498fn append_prompt_block(prompt: &mut String, block: &str) {
499 if block.is_empty() {
500 return;
501 }
502
503 if prompt.is_empty() {
504 prompt.push_str(block);
505 } else {
506 let _ = write!(prompt, "\n\n{block}");
507 }
508}
509
510fn remove_prompt_section(prompt: &mut String, section_header: &str) {
511 while let Some((section_start, section_end)) = find_prompt_section_bounds(prompt, section_header) {
512 prompt.replace_range(section_start..section_end, "");
513 }
514}
515
516fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
517 crate::prompts::sections::find_prompt_section_bounds(prompt, section_header, SectionBoundaryMode::BracketOrMarkdown)
518}
519
520fn generate_runtime_tool_guidelines_for_profile(
521 available_tools: &[String],
522 planning_active: bool,
523 shell_profile: ResolvedShellPromptProfile,
524) -> String {
525 if !planning_active {
526 return generate_tool_guidelines_for_profile(available_tools, None, shell_profile, true);
530 }
531
532 let presence = ToolPresence::of(available_tools);
533 let has_exec = presence.exec;
534 let has_search = presence.code_search;
535 let has_read_file = presence.read_file;
536 let has_list_files = presence.list_files;
537 let has_request_user_input = presence.request_user_input;
538 let has_task_tracker = presence.task_tracker;
539
540 let mut lines = vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
541 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
542 if let Some(browse_guidance) =
543 browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
544 {
545 lines.push(browse_guidance);
546 }
547 if has_exec {
548 lines.push("- In Planning workflow, use `exec_command` only for read-only verification.".to_string());
549 }
550 if has_search {
551 lines.push(CODE_SEARCH_FILTER_LINE.to_string());
552 }
553 if has_task_tracker {
554 lines.push("- Keep `task_tracker` updated as you refine the plan. Indexed updates return totals and the changed item; use action=list for the full checklist.".to_string());
555 lines.push("- Keep blockers and verification open in `task_tracker` until resolved.".to_string());
556 lines.push(PLANNING_TASK_TRACKER_INDEX_LINE.to_string());
557 }
558 if has_request_user_input {
559 lines.push(REQUEST_USER_INPUT_LINE.to_string());
560 }
561 if has_search || has_exec {
562 lines.push("- If calls repeat without progress, tighten the plan instead of retrying identically.".to_string());
563 }
564 if available_tools.iter().any(|name| name == "record_decision") {
565 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
566 }
567
568 format!("\n\n## Active Tools\n{}", lines.join("\n"))
569}
570
571fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
572 tool_snapshot
573 .active_tool_names
574 .iter()
575 .cloned()
576 .collect::<BTreeSet<_>>()
577 .into_iter()
578 .collect()
579}
580
581fn browse_tool_guidance(
582 has_exec: bool,
583 has_search: bool,
584 has_list_files: bool,
585 has_read_file: bool,
586 shell_profile: ResolvedShellPromptProfile,
587) -> Option<String> {
588 if has_exec {
589 return Some(shell_browse_guidance(shell_profile, has_search));
590 }
591
592 if !(has_search || has_list_files || has_read_file) {
593 return None;
594 }
595
596 Some("- Use available read-only repository tools for browsing; do not modify files.".to_string())
597}
598
599pub(crate) fn render_shell_profile_guidance(shell_profile: ResolvedShellPromptProfile) -> String {
600 let profile_lines = match shell_profile {
601 ResolvedShellPromptProfile::UnixLike => {
602 "- Active shell profile: `unix_like`. Use Unix-like command syntax in `exec_command.cmd`, for example `ls`, `rg`, `find`, `cat`, `sed`, and `awk`.\n- On macOS, write BSD-compatible flags for BSD tools. VT Code does not rewrite GNU flags for macOS BSD tools."
603 }
604 ResolvedShellPromptProfile::PowerShell => {
605 "- Active shell profile: `powershell`. Use native PowerShell syntax in `exec_command.cmd`, for example `Get-ChildItem`, `Select-String`, `Get-Content`, and `Where-Object`.\n- On native Windows, use WSL when you need Unix-like workflows or Unix command examples."
606 }
607 };
608 format!("## Shell Profile\n{profile_lines}\n{SHELL_PROFILE_POLICY_SUFFIX}")
609}
610
611fn shell_browse_guidance(shell_profile: ResolvedShellPromptProfile, has_search: bool) -> String {
612 const SEARCH_PREFERENCE_UNIX: &str = " Prefer `code_search` over `rg`/`grep` for code.";
617 const SEARCH_PREFERENCE_POWERSHELL: &str = " Prefer `code_search` over `Select-String` for code.";
618 match shell_profile {
619 ResolvedShellPromptProfile::UnixLike => {
620 let mut line = if has_search {
621 "- Use `exec_command.cmd` with `ls`, `find`, `cat`, `sed`, and `awk` for repository browsing."
622 .to_string()
623 } else {
624 "- Use `exec_command.cmd` with `ls`, `rg`, `find`, `cat`, `sed`, and `awk` for repository browsing."
625 .to_string()
626 };
627 if has_search {
628 line.push_str(SEARCH_PREFERENCE_UNIX);
629 }
630 line
631 }
632 ResolvedShellPromptProfile::PowerShell => {
633 let mut line = "- Use `exec_command.cmd` with native PowerShell commands such as `Get-ChildItem`, `Select-String`, `Get-Content`, and `Where-Object` for repository browsing.".to_string();
634 if has_search {
635 line.push_str(SEARCH_PREFERENCE_POWERSHELL);
636 }
637 line
638 }
639 }
640}
641
642fn shell_task_guidance(shell_profile: ResolvedShellPromptProfile) -> &'static str {
643 match shell_profile {
644 ResolvedShellPromptProfile::UnixLike => {
645 "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks. In one-shot `exec_command` calls, do not use `!!`, `!$`, `!ssh`, or `fc`; write full command arguments explicitly from conversation or tool results. Interactive shells: review-safe history expansion (Bash `histverify`, zsh `HIST_VERIFY`)."
646 }
647 ResolvedShellPromptProfile::PowerShell => {
648 "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks using native PowerShell syntax."
649 }
650 }
651}
652
653fn background_exec_guidance() -> &'static str {
654 "- For long-lived commands, set `background: true` on `exec_command`; it returns a bounded preview plus a stable `session_id` and wait arguments. At most three live background processes are retained per runtime, with no automatic eviction; `write_stdin` drives the session lifecycle."
655}
656
657fn read_only_batching_guidance(has_read_file: bool) -> &'static str {
658 if has_read_file {
659 "- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
660 } else {
661 "- Batch independent read-only calls; order dependent reads, and serialize mutations."
662 }
663}
664
665fn code_search_guidance(has_exec: bool, _shell_profile: ResolvedShellPromptProfile) -> String {
666 const BASE: &str = "- Advanced `code_search` takes `query`; filters `path`, `file_types`, `result_types`, `max_results`; results: definitions, exact syntactic usages. Queries use literal smart-case and `|`-separated literals; truncated: narrow. Example: `{\"query\":\"TurnLoop\",\"path\":\"src\",\"result_types\":[\"definition\"]}`. Do not JSON-encode arrays or integers as strings. Prefer `code_search` over `rg` on `.vtcode/context/tool_outputs/`.";
667 if has_exec {
668 format!("{BASE} Use `exec_command` or a skill for syntax patterns.")
669 } else {
670 BASE.to_string()
671 }
672}
673
674fn capability_mode_line(
675 capability_level: Option<CapabilityLevel>,
676 has_exec: bool,
677 has_file: bool,
678) -> Option<&'static str> {
679 match capability_level {
680 Some(CapabilityLevel::Basic) => {
681 Some("- Capabilities: limited. Ask the user to enable more capabilities if file work is required.")
682 }
683 Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(CAPABILITY_READ_ONLY_LINE),
684 _ if !has_exec && !has_file => Some(CAPABILITY_READ_ONLY_LINE),
685 _ => None,
686 }
687}
688
689pub(crate) fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
691 let presence = ToolPresence::of(available_tools);
692 if presence.code_search {
693 CapabilityLevel::CodeSearch
694 } else if presence.apply_patch {
695 CapabilityLevel::Editing
696 } else if presence.exec {
697 CapabilityLevel::Bash
698 } else if presence.list_files {
699 CapabilityLevel::FileListing
700 } else if presence.read_file {
701 CapabilityLevel::FileReading
702 } else {
703 CapabilityLevel::Basic
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::config::types::ShellPromptProfile;
711
712 fn guidelines_for(tools: &[String], capability_level: Option<CapabilityLevel>) -> String {
715 generate_tool_guidelines_for_profile(
716 tools,
717 capability_level,
718 ShellPromptProfile::Auto.resolve_for_current_platform(),
719 true,
720 )
721 }
722
723 fn rebuilt_model_tool_prompt(
726 mut prompt: String,
727 snapshot: &SessionToolCatalogSnapshot,
728 metadata: bool,
729 shell: ResolvedShellPromptProfile,
730 provider: &dyn crate::llm::provider::LLMProvider,
731 model: &str,
732 config: &crate::config::VTCodeConfig,
733 ) -> String {
734 append_runtime_tool_prompt_sections_for_profile(&mut prompt, snapshot, metadata, shell);
735 let names = snapshot_tool_names(snapshot);
736 let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
737 let profile = ToolGuidanceProfile::resolve(
738 crate::compaction::effective_context_budget(Some(config), provider, model),
739 vtcode_commons::estimate_tokens(&prompt),
740 config.agent.max_system_prompt_tokens as usize,
741 pricing.and_then(|price| price.input),
742 config.agent.harness.max_budget_usd,
743 );
744 if snapshot.planning_active && profile == ToolGuidanceProfile::Default {
745 return prompt;
746 }
747 remove_prompt_section(&mut prompt, "## Active Tools");
748 let mut guidance = generate_tool_guidelines_with_capabilities(
749 &names,
750 Some(infer_capability_level(&names)),
751 shell,
752 profile,
753 provider.supports_parallel_tool_config(model),
754 );
755 if snapshot.planning_active {
756 append_minimal_planning_addendum(&mut guidance, &names);
757 }
758 append_prompt_block(&mut prompt, guidance.trim_start_matches('\n'));
759 prompt
760 }
761
762 #[test]
763 fn model_tool_prompt_reuse_matches_rebuild_across_density_and_capabilities() {
764 use crate::config::constants::models;
765 use crate::llm::provider::LLMProvider;
766 use crate::llm::providers::{AnthropicProvider, HuggingFaceProvider};
767
768 struct ProviderCase {
769 provider: Box<dyn LLMProvider>,
770 model: &'static str,
771 parallel: bool,
772 }
773 let providers = [
774 ProviderCase {
775 provider: Box::new(AnthropicProvider::new("offline-fixture".into())),
776 model: models::anthropic::DEFAULT_MODEL,
777 parallel: true,
778 },
779 ProviderCase {
780 provider: Box::new(HuggingFaceProvider::new("offline-fixture".into())),
781 model: "offline-fixture",
782 parallel: false,
783 },
784 ];
785 let initial = "Base prompt\n\n## Active Tools\nold guidance\n\n# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.\n\n[Runtime Tool Catalog]\nold metadata\n\n## Tail\nKeep this section.\n\n";
786 for case in providers {
787 assert_eq!(case.provider.supports_parallel_tool_config(case.model), case.parallel);
788 for names in [
789 None,
790 Some(&[][..]),
791 Some(&[TOOL_TASK_TRACKER, TOOL_REQUEST_USER_INPUT][..]),
792 Some(&[TOOL_READ_FILE][..]),
793 Some(&[TOOL_WRITE_STDIN][..]),
794 Some(&[TOOL_EXEC_COMMAND, TOOL_APPLY_PATCH, TOOL_CODE_SEARCH, "record_decision"][..]),
795 ] {
796 for planning in [false, true] {
797 let snapshot = SessionToolCatalogSnapshot::new(
798 7,
799 9,
800 planning,
801 true,
802 names.map(|names| {
803 std::sync::Arc::new(
804 names
805 .iter()
806 .map(|name| {
807 ToolDefinition::function(
808 (*name).to_string(),
809 "fixture".to_string(),
810 serde_json::json!({"type": "object"}),
811 )
812 })
813 .collect(),
814 )
815 }),
816 false,
817 );
818 for shell in [
819 ResolvedShellPromptProfile::UnixLike,
820 ResolvedShellPromptProfile::PowerShell,
821 ] {
822 for metadata in [false, true] {
823 let mut measured = initial.to_string();
824 append_runtime_tool_prompt_sections_for_profile(&mut measured, &snapshot, metadata, shell);
825 let measured_tokens = vtcode_commons::estimate_tokens(&measured) as u64;
826 for budget in [1, measured_tokens - 1, measured_tokens, 100_000] {
827 let mut config = crate::config::VTCodeConfig::default();
828 config.context.max_context_tokens = 128_000;
829 config.agent.max_system_prompt_tokens = budget;
830 let expected = rebuilt_model_tool_prompt(
831 initial.to_string(),
832 &snapshot,
833 metadata,
834 shell,
835 case.provider.as_ref(),
836 case.model,
837 &config,
838 );
839 let mut actual = initial.to_string();
840 append_runtime_tool_prompt_sections_for_model(
841 &mut actual,
842 &snapshot,
843 metadata,
844 shell,
845 case.provider.as_ref(),
846 case.model,
847 Some(&config),
848 );
849
850 assert_eq!(
851 actual,
852 expected,
853 "provider={}, names={names:?}, planning={planning}, shell={shell:?}, metadata={metadata}, budget={budget}",
854 case.provider.name()
855 );
856 assert_eq!(
857 actual.matches("## Active Tools").count(),
858 usize::from(
859 budget >= measured_tokens || names.is_some_and(|names| !names.is_empty())
860 )
861 );
862 assert!(
863 actual.contains("# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.")
864 );
865 assert!(actual.contains("## Tail\nKeep this section."));
866 }
867 }
868 }
869 }
870 }
871 }
872 }
873
874 #[test]
875 fn matrix_guidance_has_presence_and_budget_in_both_densities() {
876 for profile in [ToolGuidanceProfile::Default, ToolGuidanceProfile::Minimal] {
877 let enabled = generate_tool_guidelines_with_capabilities(
878 &["matrix".to_string(), "request_user_input".to_string()],
879 None,
880 ResolvedShellPromptProfile::UnixLike,
881 profile,
882 false,
883 );
884 let disabled = generate_tool_guidelines_with_capabilities(
885 &["request_user_input".to_string()],
886 None,
887 ResolvedShellPromptProfile::UnixLike,
888 profile,
889 false,
890 );
891 assert_eq!(enabled.matches(MATRIX_GUIDANCE).count(), 1);
892 assert!(enabled.contains("declared input changes"));
893 assert!(enabled.contains("effective permissions and sandbox gates"));
894 assert!(enabled.contains("Replay restores admission before discovery"));
895 assert!(enabled.contains("user cancel is terminal"));
896 assert!(!disabled.contains("scheduler-owned"));
897 assert!(vtcode_commons::estimate_tokens(&enabled) <= 220, "{profile:?}: {enabled}");
898 }
899 }
900
901 #[test]
905 fn universal_rules_have_one_home_across_composed_prompt_sections() {
906 use crate::config::types::SystemPromptMode;
907 use crate::prompts::harness_limits::upsert_harness_limits_section;
908 use crate::prompts::static_prompts::static_profile_prompt;
909 use crate::prompts::system::PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE;
910
911 let shell = ResolvedShellPromptProfile::UnixLike;
912 let execution_tools = [
913 TOOL_EXEC_COMMAND,
914 TOOL_WRITE_STDIN,
915 TOOL_APPLY_PATCH,
916 TOOL_CODE_SEARCH,
917 TOOL_TASK_TRACKER,
918 TOOL_START_PLANNING,
919 TOOL_REQUEST_USER_INPUT,
920 ]
921 .map(str::to_owned)
922 .to_vec();
923 let planning_tools = [
924 TOOL_EXEC_COMMAND,
925 TOOL_WRITE_STDIN,
926 TOOL_CODE_SEARCH,
927 TOOL_GREP_FILE,
928 TOOL_TASK_TRACKER,
929 TOOL_REQUEST_USER_INPUT,
930 ]
931 .map(str::to_owned)
932 .to_vec();
933 let mut minimal_planning = generate_tool_guidelines_with_capabilities(
934 &planning_tools,
935 None,
936 shell,
937 ToolGuidanceProfile::Minimal,
938 false,
939 );
940 append_minimal_planning_addendum(&mut minimal_planning, &planning_tools);
941 let variants = [
942 ("default", generate_tool_guidelines_for_profile(&execution_tools, None, shell, true), false),
943 (
944 "minimal",
945 generate_tool_guidelines_with_capabilities(
946 &execution_tools,
947 None,
948 shell,
949 ToolGuidanceProfile::Minimal,
950 true,
951 ),
952 false,
953 ),
954 ("default planning", generate_runtime_tool_guidelines_for_profile(&planning_tools, true, shell), true),
955 ("minimal planning", minimal_planning, true),
956 ];
957 let markers = [
960 "never claim a check passed",
961 "Diagnose failures; change approach",
962 "Use returned `next_wait_args`",
963 "Treat empty searches as evidence",
964 "Check optional tools once",
965 "small non-overlapping ranges",
966 "accumulated output never exhausts tool access",
967 "additional_permissions",
968 "bypass safeguards",
969 "Delegate only sizeable",
970 "Across compaction",
971 ];
972
973 for mode in [
974 SystemPromptMode::Default,
975 SystemPromptMode::Minimal,
976 SystemPromptMode::Lightweight,
977 SystemPromptMode::Specialized,
978 ] {
979 for (variant, guidance, planning) in &variants {
980 let mut prompt = static_profile_prompt(mode).to_owned();
981 if *planning {
982 prompt.push_str("\n\n");
983 prompt.push_str(PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE);
984 }
985 prompt.push_str(guidance);
986 upsert_harness_limits_section(&mut prompt, 32, 600, 2);
987 for marker in markers {
988 assert_eq!(
989 prompt.matches(marker).count(),
990 1,
991 "{mode:?} with {variant} tool guidance should state {marker:?} exactly once"
992 );
993 }
994 let start_planning_mentions = prompt.matches("start_planning").count();
995 assert_eq!(start_planning_mentions, usize::from(!*planning), "{mode:?} with {variant} tool guidance");
996 }
997 }
998 }
999
1000 #[test]
1001 fn documentation_profile_respects_context_tokens_and_known_cost() {
1002 assert_eq!(ToolGuidanceProfile::resolve(32_000, 100, 1000, None, None), ToolGuidanceProfile::Minimal);
1003 assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 100, 1000, None, Some(0.0)), ToolGuidanceProfile::Default);
1004 assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 1001, 1000, None, None), ToolGuidanceProfile::Minimal);
1005 assert_eq!(
1006 ToolGuidanceProfile::resolve(1_000_000, 1000, 2000, Some(0.00001), Some(0.001)),
1007 ToolGuidanceProfile::Minimal
1008 );
1009 }
1010
1011 #[test]
1012 fn minimal_and_default_tool_guidance_snapshots() {
1013 let tools = vec![TOOL_READ_FILE.to_owned()];
1014 let minimal = generate_tool_guidelines_with_capabilities(
1015 &tools,
1016 None,
1017 ResolvedShellPromptProfile::UnixLike,
1018 ToolGuidanceProfile::Minimal,
1019 false,
1020 );
1021 assert_eq!(
1022 minimal,
1023 "\n\n## Active Tools\n- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.\n- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.\n- Use available read-only repository tools for browsing; do not modify files."
1024 );
1025 let default = generate_tool_guidelines_with_capabilities(
1026 &tools,
1027 None,
1028 ResolvedShellPromptProfile::UnixLike,
1029 ToolGuidanceProfile::Default,
1030 false,
1031 );
1032 assert_eq!(
1033 default,
1034 "\n\n## Active Tools\n- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.\n- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.\n- Use available read-only repository tools for browsing; do not modify files.\n- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
1035 );
1036 }
1037
1038 #[test]
1039 fn public_decision_guidance_is_optional_and_bounded() {
1040 for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1041 let enabled = generate_tool_guidelines_with_capabilities(
1042 &[TOOL_READ_FILE.into(), "record_decision".into()],
1043 None,
1044 ResolvedShellPromptProfile::UnixLike,
1045 profile,
1046 false,
1047 );
1048 let disabled = generate_tool_guidelines_with_capabilities(
1049 &[TOOL_READ_FILE.into()],
1050 None,
1051 ResolvedShellPromptProfile::UnixLike,
1052 profile,
1053 false,
1054 );
1055 assert!(enabled.contains(PUBLIC_DECISION_GUIDANCE));
1056 assert!(!disabled.contains("record_decision"));
1057 assert!(enabled.len().saturating_sub(disabled.len()) < 300);
1058 assert!(!enabled.contains("chain of thought"));
1059 }
1060 }
1061
1062 #[test]
1063 fn tool_guidance_uses_actual_parallel_capabilities_for_three_families() {
1064 use crate::config::constants::models;
1065 use crate::llm::provider::LLMProvider;
1066 use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
1067 let providers: [(Box<dyn LLMProvider>, &str); 3] = [
1068 (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
1069 (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
1070 (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
1071 ];
1072 for (provider, model) in providers {
1073 for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1074 let parallel = provider.supports_parallel_tool_config(model);
1075 let text = generate_tool_guidelines_with_capabilities(
1076 &[TOOL_EXEC_COMMAND.to_owned()],
1077 None,
1078 ResolvedShellPromptProfile::UnixLike,
1079 profile,
1080 parallel,
1081 );
1082 assert_eq!(text.contains("tools in parallel"), parallel);
1083 let serial = generate_tool_guidelines_with_capabilities(
1084 &[TOOL_EXEC_COMMAND.to_owned()],
1085 None,
1086 ResolvedShellPromptProfile::UnixLike,
1087 profile,
1088 false,
1089 );
1090 assert!(!serial.contains("tools in parallel"));
1091 }
1092 }
1093 }
1094
1095 #[test]
1096 fn test_read_only_capability_detection() {
1097 let tools = vec![TOOL_CODE_SEARCH.to_string()];
1098 let guidelines = guidelines_for(&tools, None);
1099 assert!(guidelines.contains("Capabilities: read-only"));
1100 assert!(guidelines.contains("do not modify files"));
1101 }
1102
1103 #[test]
1104 fn test_tool_preference_guidance() {
1105 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_CODE_SEARCH.to_string()];
1106 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1107 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1108 assert!(guidelines.contains("literal smart-case"));
1109 assert!(guidelines.contains("exact syntactic usages"));
1110 assert!(guidelines.contains("\"result_types\":[\"definition\"]"));
1111 assert!(guidelines.contains("Do not JSON-encode arrays or integers as strings"));
1112 assert!(guidelines.contains("omit unused filters"));
1113 assert!(guidelines.contains("path: \"\""));
1114 assert!(guidelines.contains("git diff -- <path>"));
1115 assert!(guidelines.contains("build tools"));
1116 assert!(guidelines.contains("test tools"));
1117 assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1121 assert!(guidelines.contains("Run fast checks before full builds"));
1123 assert!(!guidelines.contains("Completion is a checkpoint"));
1126 }
1127
1128 #[test]
1129 fn test_edit_workflow_guidance() {
1130 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1131 let guidelines = guidelines_for(&tools, None);
1132 assert!(guidelines.contains("Use `apply_patch`"));
1133 assert!(guidelines.contains("patches small"));
1134 assert!(!guidelines.contains("verification resolved"));
1137 }
1138
1139 #[test]
1140 fn test_vt_code_guidance_omits_task_tracker() {
1141 let tools = vec![
1142 TOOL_EXEC_COMMAND.to_string(),
1143 TOOL_WRITE_STDIN.to_string(),
1144 TOOL_APPLY_PATCH.to_string(),
1145 ];
1146 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1147
1148 assert!(guidelines.contains("exec_command.cmd"));
1149 for command in ["ls", "rg", "find", "cat", "sed", "awk"] {
1150 assert!(
1151 guidelines.contains(&format!("`{command}`")),
1152 "{command} should be shown as an exec_command.cmd example"
1153 );
1154 }
1155 assert!(guidelines.contains("`write_stdin`"));
1156 assert!(guidelines.contains("At most three live background processes"));
1157 assert!(guidelines.contains("`apply_patch`"));
1158 assert!(!guidelines.contains("task_tracker"));
1159 assert!(!guidelines.contains("list_files"));
1160 assert!(!guidelines.contains("read_file"));
1161 }
1162
1163 #[test]
1164 fn task_tracker_guidance_explains_action_aware_indices() {
1165 let guidelines = generate_runtime_tool_guidelines_for_profile(
1166 &[TOOL_TASK_TRACKER.to_string()],
1167 true,
1168 ResolvedShellPromptProfile::UnixLike,
1169 );
1170
1171 assert!(guidelines.contains(&format!("\n{PLANNING_TASK_TRACKER_INDEX_LINE}")));
1172 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("positive flat indices"));
1173 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("(index 0 is invalid while planning)"));
1174 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("full checklist replacement"));
1175 for line in [PLANNING_TASK_TRACKER_INDEX_LINE, PLANNING_TASK_TRACKER_COMPACT_LINE] {
1178 assert!(line.contains("index 0 is invalid while planning"), "{line}");
1179 assert!(!line.contains("reserved"), "{line}");
1180 assert!(!line.contains("index: 0"), "{line}");
1181 }
1182 }
1183
1184 #[test]
1185 fn unix_like_guidance_makes_command_reuse_explicit() {
1186 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_WRITE_STDIN.to_string()];
1187 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1188
1189 assert!(guidelines.contains("one-shot `exec_command` calls"));
1190 assert!(guidelines.contains("`!!`, `!$`, `!ssh`, or `fc`"));
1191 assert!(guidelines.contains("write full command arguments explicitly"));
1192 assert!(guidelines.contains("conversation or tool results"));
1193 assert!(guidelines.contains("existing `session_id`"));
1194 assert!(guidelines.contains("background: true"));
1195 assert!(guidelines.contains("Bash `histverify`"));
1196 assert!(guidelines.contains("zsh `HIST_VERIFY`"));
1197 assert!(guidelines.contains("`Exec session resume:`"));
1200 assert!(guidelines.contains("prior turn ended mid-run"));
1201 assert!(!guidelines.contains("Prefer `code_search` over `rg`"));
1204 }
1205
1206 #[test]
1207 fn write_stdin_guidance_advertises_cross_turn_resume_hint() {
1208 let tools = vec![TOOL_WRITE_STDIN.to_string()];
1209 let default_guidance =
1210 generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1211 assert!(default_guidance.contains("`Exec session resume:`"));
1212 assert!(default_guidance.contains("prior turn ended mid-run"));
1213 assert!(default_guidance.contains("if missing, recover prior output"));
1214 assert!(default_guidance.contains("rerun only for fresh results"));
1215
1216 let minimal = generate_tool_guidelines_with_capabilities(
1217 &tools,
1218 None,
1219 ResolvedShellPromptProfile::UnixLike,
1220 ToolGuidanceProfile::Minimal,
1221 false,
1222 );
1223 assert!(minimal.contains("`Exec session resume:`"));
1224 assert!(minimal.contains("prior turn ended mid-run"));
1225 assert!(minimal.contains("if missing, recover prior output"));
1226 }
1227
1228 #[test]
1229 fn powershell_guidance_uses_native_command_examples() {
1230 let tools = vec![
1231 TOOL_EXEC_COMMAND.to_string(),
1232 TOOL_CODE_SEARCH.to_string(),
1233 TOOL_APPLY_PATCH.to_string(),
1234 ];
1235 let guidelines =
1236 generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::PowerShell, true);
1237
1238 assert!(guidelines.contains("native PowerShell commands"));
1239 assert!(guidelines.contains("`Get-ChildItem`"));
1240 assert!(guidelines.contains("`Select-String`"));
1241 assert!(guidelines.contains("native PowerShell syntax"));
1242 assert!(guidelines.contains("Prefer `code_search` over `Select-String` for code"));
1243 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1244 assert!(guidelines.contains("literal smart-case"));
1245 assert!(guidelines.contains("omit unused filters"));
1246 assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1247 assert!(!guidelines.contains("shell history expansion"));
1248 assert!(!guidelines.contains("histverify"));
1249 assert!(!guidelines.contains("HIST_VERIFY"));
1250 }
1251
1252 #[test]
1253 fn shell_profile_prompt_keeps_policy_and_syntax_separate() {
1254 let unix = render_shell_profile_guidance(ResolvedShellPromptProfile::UnixLike);
1255 assert!(unix.contains("Active shell profile: `unix_like`"));
1256 assert!(unix.contains("does not rewrite GNU flags for macOS BSD tools"));
1257 assert!(unix.contains("controls prompt examples and expected command syntax only"));
1258 assert!(unix.contains("does not translate GNU-to-BSD"));
1259
1260 let powershell = render_shell_profile_guidance(ResolvedShellPromptProfile::PowerShell);
1261 assert!(powershell.contains("Active shell profile: `powershell`"));
1262 assert!(powershell.contains("WSL"));
1263 assert!(powershell.contains("Unix-like workflows"));
1264 assert!(powershell.contains("PowerShell-to-Unix"));
1265 }
1266
1267 #[test]
1268 fn test_harness_browse_tool_guidance() {
1269 let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
1270 let guidelines = guidelines_for(&tools, None);
1271 assert!(guidelines.contains("available read-only repository tools"));
1272 assert!(guidelines.contains("bounded `read_file` ranges"));
1273 assert!(!guidelines.contains("list_files"));
1274 assert!(!guidelines.contains("offset"));
1275 assert!(!guidelines.contains("per_page"));
1276 }
1277
1278 #[test]
1279 fn test_canonical_browse_tool_guidance_prefers_public_tools() {
1280 let tools = vec![
1281 TOOL_CODE_SEARCH.to_string(),
1282 TOOL_LIST_FILES.to_string(),
1283 "read_file".to_string(),
1284 ];
1285 let guidelines = guidelines_for(&tools, None);
1286 assert!(guidelines.contains("available read-only repository tools"));
1287 assert!(guidelines.contains("code_search"));
1288 assert!(guidelines.contains("bounded `read_file` ranges"));
1289 }
1290
1291 #[test]
1292 fn test_capability_basic_guidance() {
1293 let tools = vec![];
1294 let guidelines = guidelines_for(&tools, Some(CapabilityLevel::Basic));
1295 assert!(guidelines.contains("Capabilities: limited"));
1296 assert!(guidelines.contains("enable more capabilities"));
1297 }
1298
1299 #[test]
1300 fn test_capability_file_reading_guidance() {
1301 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1302 let guidelines = guidelines_for(&tools, Some(CapabilityLevel::FileReading));
1303 assert!(guidelines.contains("Capabilities: read-only"));
1304 assert!(guidelines.contains("do not modify"));
1305 }
1306
1307 #[test]
1308 fn test_full_capabilities_no_special_guidance() {
1309 let tools = vec![
1310 TOOL_APPLY_PATCH.to_string(),
1311 TOOL_EXEC_COMMAND.to_string(),
1312 TOOL_CODE_SEARCH.to_string(),
1313 ];
1314 let guidelines = generate_tool_guidelines_for_profile(
1315 &tools,
1316 Some(CapabilityLevel::Editing),
1317 ResolvedShellPromptProfile::UnixLike,
1318 true,
1319 );
1320
1321 assert!(!guidelines.contains("Capabilities: limited"));
1322 assert!(!guidelines.contains("Capabilities: read-only"));
1323 }
1324
1325 #[test]
1326 fn test_empty_tools_shows_read_only_capabilities() {
1327 let tools = vec![];
1328 let guidelines = guidelines_for(&tools, None);
1329 assert!(guidelines.contains("Capabilities: read-only"));
1330 }
1331
1332 #[test]
1333 fn test_planning_workflow_guidance_keeps_verification_open() {
1334 let tools = vec![
1335 TOOL_EXEC_COMMAND.to_string(),
1336 TOOL_TASK_TRACKER.to_string(),
1337 TOOL_CODE_SEARCH.to_string(),
1338 ];
1339 let guidelines =
1340 generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1341 assert!(guidelines.contains("Keep `task_tracker` updated"));
1342 assert!(guidelines.contains("blockers and verification open"));
1343 }
1344
1345 #[test]
1346 fn test_capability_inference_precedence() {
1347 let tools = vec![TOOL_APPLY_PATCH.to_string(), TOOL_CODE_SEARCH.to_string()];
1348 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1349
1350 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_APPLY_PATCH.to_string()];
1351 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1352 }
1353
1354 #[test]
1355 fn test_capability_inference_variants() {
1356 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1357 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1358
1359 let tools = vec![TOOL_EXEC_COMMAND.to_string()];
1360 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
1361
1362 let tools = vec![TOOL_CODE_SEARCH.to_string()];
1363 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1364
1365 let tools = vec![TOOL_LIST_FILES.to_string()];
1366 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
1367
1368 let tools = vec!["read_file".to_string()];
1369 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
1370
1371 let tools = vec!["unknown_tool".to_string()];
1372 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
1373 }
1374
1375 #[test]
1376 fn test_guidelines_stay_compact() {
1377 let tools = vec![
1378 TOOL_EXEC_COMMAND.to_string(),
1379 TOOL_CODE_SEARCH.to_string(),
1380 "read_file".to_string(),
1381 TOOL_LIST_FILES.to_string(),
1382 "apply_patch".to_string(),
1383 ];
1384 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1385 assert!(guidelines.contains("Batch independent read-only calls"));
1386 assert!(guidelines.contains("code_search"));
1387 assert!(guidelines.contains("static read-only filtering pipelines use fail-closed `pipefail`"));
1390 assert!(guidelines.contains("Only terminal exit 0 clears verification"));
1391 assert!(guidelines.contains("dynamic syntax, mutating tails, `;`, and `||` do not qualify"));
1392 assert!(!guidelines.contains("elided"));
1393 assert!(guidelines.contains("max_output_tokens"));
1394 assert!(guidelines.contains("Build and Auto share tools and safety gates"));
1395 let approx_tokens = vtcode_commons::estimate_tokens(&guidelines);
1396 assert!(approx_tokens < 580, "got ~{approx_tokens} tokens");
1403 }
1404
1405 #[test]
1406 fn deferred_tools_section_caps_groups_and_truncates_descriptions() {
1407 use crate::llm::provider::ToolNamespace;
1408
1409 fn deferred_mcp_tool(server: &str, tool: &str, description: &str) -> ToolDefinition {
1410 let mut definition = ToolDefinition::function(
1411 format!("mcp__{server}__{tool}"),
1412 "deferred".to_string(),
1413 serde_json::json!({"type": "object"}),
1414 );
1415 definition.namespace = Some(ToolNamespace {
1416 name: server.to_string(),
1417 description: description.to_string(),
1418 });
1419 definition.defer_loading = Some(true);
1420 definition
1421 }
1422
1423 let mut tools = Vec::new();
1424 for index in 0..10 {
1425 tools.push(deferred_mcp_tool(&format!("server-{index:02}"), "search", "Tools provided by MCP server"));
1426 }
1427 tools.push(deferred_mcp_tool("server-long", "search", &"d".repeat(500)));
1429
1430 let mut prompt = "Base prompt".to_string();
1431 append_deferred_tools_prompt_section(&mut prompt, &tools);
1432
1433 assert!(prompt.contains("[Deferred Tools]"));
1434 assert!(prompt.contains("server-00 (1 tools)"));
1435 assert!(prompt.contains("server-07 (1 tools)"));
1436 assert!(!prompt.contains("server-08 (1 tools)"), "groups past the cap must collapse into overflow");
1437 assert!(!prompt.contains("server-09 (1 tools)"));
1438 assert!(
1439 prompt.contains("(+3 more deferred groups available"),
1440 "10 named groups + 1 long group over the 8-group cap overflows by 3"
1441 );
1442 assert!(!prompt.contains(&"d".repeat(121)), "group descriptions must stay truncated");
1443 assert!(prompt.contains("Use `search_tools` to find a deferred capability"));
1444
1445 append_deferred_tools_prompt_section(&mut prompt, &tools);
1447 assert_eq!(prompt.matches("[Deferred Tools]").count(), 1);
1448 }
1449
1450 #[test]
1451 fn deferred_tools_section_omits_empty_and_fully_loaded_groups() {
1452 let mut prompt = "Base prompt".to_string();
1453 append_deferred_tools_prompt_section(&mut prompt, &[]);
1454 assert!(!prompt.contains("[Deferred Tools]"));
1455
1456 let loaded = ToolDefinition::function(
1457 "exec_command".to_string(),
1458 "Shell".to_string(),
1459 serde_json::json!({"type": "object"}),
1460 );
1461 let mut prompt = "Base prompt".to_string();
1462 append_deferred_tools_prompt_section(&mut prompt, &[loaded]);
1463 assert!(!prompt.contains("[Deferred Tools]"));
1464 }
1465
1466 #[test]
1467 fn test_parallel_tool_call_guidance() {
1468 let tools = vec![
1469 TOOL_EXEC_COMMAND.to_string(),
1470 TOOL_CODE_SEARCH.to_string(),
1471 TOOL_APPLY_PATCH.to_string(),
1472 ];
1473 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1474 assert!(guidelines.contains("parallel"), "Should include parallel tool call guidance");
1475 assert!(guidelines.contains("inputs do not depend"), "Should mention independent inputs");
1476 }
1477
1478 #[test]
1479 fn test_read_only_batching_guidance_is_explicit() {
1480 let tools = vec![
1481 TOOL_CODE_SEARCH.to_string(),
1482 TOOL_READ_FILE.to_string(),
1483 TOOL_LIST_FILES.to_string(),
1484 ];
1485 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1486
1487 assert!(guidelines.contains("Batch independent read-only calls"));
1488 assert!(guidelines.contains("`read_file` ranges"));
1489 assert!(guidelines.contains("serialize mutations"));
1490 assert!(!guidelines.contains("Only terminal exit 0 clears verification"));
1493 }
1494
1495 #[test]
1496 fn execution_agents_can_suggest_planning_for_demanding_tasks() {
1497 let tools = vec![TOOL_START_PLANNING.to_string(), TOOL_EXEC_COMMAND.to_string()];
1498 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1499
1500 assert_eq!(guidelines.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1501 let minimal = generate_tool_guidelines_with_capabilities(
1502 &tools,
1503 None,
1504 ResolvedShellPromptProfile::UnixLike,
1505 ToolGuidanceProfile::Minimal,
1506 false,
1507 );
1508 assert_eq!(minimal.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1509 let without = generate_tool_guidelines_for_profile(
1511 &[TOOL_EXEC_COMMAND.to_string()],
1512 None,
1513 ResolvedShellPromptProfile::UnixLike,
1514 true,
1515 );
1516 assert!(!without.contains("start_planning"));
1517 }
1518
1519 #[test]
1520 fn markdown_validation_is_optional_in_planning_and_execution() {
1521 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_READ_FILE.to_string()];
1522 for planning in [false, true] {
1523 let guidance =
1524 generate_runtime_tool_guidelines_for_profile(&tools, planning, ResolvedShellPromptProfile::UnixLike);
1525 assert_eq!(guidance.matches(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE).count(), 1);
1526 assert!(guidance.contains("report skipped"));
1527 assert!(guidance.contains("without installing tools"));
1528 assert!(guidance.contains("Lint errors remain failures"));
1529 }
1530 }
1531
1532 #[test]
1533 fn planning_workflow_runtime_guidance_keeps_exec_read_only() {
1534 let tools = vec![
1535 TOOL_APPLY_PATCH.to_string(),
1536 TOOL_EXEC_COMMAND.to_string(),
1537 TOOL_CODE_SEARCH.to_string(),
1538 ];
1539 let guidelines =
1540 generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1541
1542 assert!(guidelines.contains("Planning workflow active"));
1543 assert!(guidelines.contains("`exec_command` only for read-only verification"));
1544 assert!(!guidelines.contains("<proposed_plan>"));
1545 assert!(!guidelines.contains("Every implementation step"));
1546 assert!(guidelines.contains("omit unused filters"));
1547 assert!(!guidelines.contains("Inspect before edit"));
1548 }
1549
1550 #[test]
1551 fn runtime_tool_guidance_uses_explicit_powershell_profile() {
1552 let tools = vec![
1553 TOOL_APPLY_PATCH.to_string(),
1554 TOOL_EXEC_COMMAND.to_string(),
1555 TOOL_CODE_SEARCH.to_string(),
1556 ];
1557 let guidelines =
1558 generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::PowerShell);
1559
1560 assert!(guidelines.contains("native PowerShell commands"));
1561 assert!(guidelines.contains("`Get-ChildItem`"));
1562 assert!(guidelines.contains("`Select-String`"));
1563 assert!(guidelines.contains("native PowerShell syntax"));
1564 assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1565 }
1566
1567 #[test]
1568 fn runtime_tool_guidance_uses_explicit_unix_like_profile() {
1569 let tools = vec![
1570 TOOL_APPLY_PATCH.to_string(),
1571 TOOL_EXEC_COMMAND.to_string(),
1572 TOOL_CODE_SEARCH.to_string(),
1573 ];
1574 let guidelines =
1575 generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::UnixLike);
1576
1577 assert!(guidelines.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1578 assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1579 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1580 assert!(guidelines.contains("literal smart-case"));
1581 assert!(guidelines.contains("shell-only tasks"));
1582 assert!(!guidelines.contains("native PowerShell commands"));
1583 assert!(!guidelines.contains("`Get-ChildItem`"));
1584 }
1585
1586 #[test]
1587 fn runtime_tool_prompt_sections_use_explicit_profile_for_active_tools() {
1588 let mut powershell_prompt = "Base prompt".to_string();
1589 let mut unix_prompt = "Base prompt".to_string();
1590 let snapshot = SessionToolCatalogSnapshot::new(
1591 7,
1592 9,
1593 false,
1594 false,
1595 Some(std::sync::Arc::new(vec![
1596 ToolDefinition::function(
1597 TOOL_EXEC_COMMAND.to_string(),
1598 "Shell".to_string(),
1599 serde_json::json!({"type": "object"}),
1600 ),
1601 ToolDefinition::function(
1602 TOOL_CODE_SEARCH.to_string(),
1603 "Bounded source search".to_string(),
1604 serde_json::json!({"type": "object"}),
1605 ),
1606 ])),
1607 false,
1608 );
1609
1610 append_runtime_tool_prompt_sections_for_profile(
1611 &mut powershell_prompt,
1612 &snapshot,
1613 false,
1614 ResolvedShellPromptProfile::PowerShell,
1615 );
1616 append_runtime_tool_prompt_sections_for_profile(
1617 &mut unix_prompt,
1618 &snapshot,
1619 false,
1620 ResolvedShellPromptProfile::UnixLike,
1621 );
1622
1623 assert!(powershell_prompt.contains("## Active Tools"));
1624 assert!(powershell_prompt.contains("`Get-ChildItem`"));
1625 assert!(powershell_prompt.contains("`Select-String`"));
1626 assert!(!powershell_prompt.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1627
1628 assert!(unix_prompt.contains("## Active Tools"));
1629 assert!(unix_prompt.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1630 assert!(unix_prompt.contains("Prefer `code_search` over `rg`/`grep` for code"));
1631 assert!(unix_prompt.contains("Advanced `code_search` takes `query`"));
1632 assert!(unix_prompt.contains("literal smart-case"));
1633 assert!(!unix_prompt.contains("`Get-ChildItem`"));
1634 }
1635
1636 #[test]
1637 fn runtime_tool_prompt_sections_include_catalog_metadata() {
1638 let mut prompt = "Base prompt".to_string();
1639 let snapshot = SessionToolCatalogSnapshot::new(
1640 7,
1641 9,
1642 true,
1643 false,
1644 Some(std::sync::Arc::new(vec![
1645 ToolDefinition::function(
1646 TOOL_EXEC_COMMAND.to_string(),
1647 "Search".to_string(),
1648 serde_json::json!({"type": "object"}),
1649 ),
1650 ToolDefinition::function(
1651 TOOL_APPLY_PATCH.to_string(),
1652 "File".to_string(),
1653 serde_json::json!({"type": "object"}),
1654 ),
1655 ])),
1656 false,
1657 );
1658
1659 append_runtime_tool_prompt_sections_for_profile(
1660 &mut prompt,
1661 &snapshot,
1662 true,
1663 ShellPromptProfile::Auto.resolve_for_current_platform(),
1664 );
1665
1666 assert!(prompt.contains("## Active Tools"));
1667 assert!(prompt.contains("[Runtime Tool Catalog]"));
1668 assert!(prompt.contains("catalog_tools: 2"));
1669 assert!(prompt.contains("currently_available_tools: exec_command, apply_patch"));
1670 assert!(prompt.contains("request_user_input_enabled: false"));
1671 }
1672
1673 #[test]
1674 fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
1675 let mut prompt = "Base prompt".to_string();
1676 let first = SessionToolCatalogSnapshot::new(
1677 1,
1678 2,
1679 false,
1680 false,
1681 Some(std::sync::Arc::new(vec![ToolDefinition::function(
1682 TOOL_EXEC_COMMAND.to_string(),
1683 "Search".to_string(),
1684 serde_json::json!({"type": "object"}),
1685 )])),
1686 false,
1687 );
1688 let second = SessionToolCatalogSnapshot::new(
1689 7,
1690 9,
1691 true,
1692 true,
1693 Some(std::sync::Arc::new(vec![ToolDefinition::function(
1694 TOOL_APPLY_PATCH.to_string(),
1695 "File".to_string(),
1696 serde_json::json!({"type": "object"}),
1697 )])),
1698 false,
1699 );
1700
1701 let shell = ShellPromptProfile::Auto.resolve_for_current_platform();
1702 append_runtime_tool_prompt_sections_for_profile(&mut prompt, &first, true, shell);
1703 append_runtime_tool_prompt_sections_for_profile(&mut prompt, &second, true, shell);
1704
1705 assert_eq!(prompt.matches("## Active Tools").count(), 1);
1706 assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
1707 assert!(prompt.contains("version: 7"));
1708 assert!(!prompt.contains("version: 1"));
1709 assert!(prompt.contains("request_user_input_enabled: true"));
1710 assert!(!prompt.contains("request_user_input_enabled: false"));
1711 }
1712}