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 = "- `task_tracker`: list {\"action\":\"list\"}; updates use positive index/index_path or items, never conflicting fields; index 0 is invalid while planning. Keep blockers and verification open.";
44const PLANNING_TASK_TRACKER_INDEX_LINE: &str = "- `task_tracker`: list {\"action\":\"list\"}; update {\"action\":\"update\",\"index\":1,\"status\":\"in_progress\"}; bulk {\"action\":\"update\",\"items\":[\"[x] Done\",\"[ ] Remaining\"]}. Use positive flat indices or hierarchical index_path (index 0 is invalid while planning), or items for full checklist replacement. Omit unused fields; never combine index, index_path, and items.";
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 record_decision: bool,
121}
122
123impl ToolPresence {
124 fn of(available_tools: &[String]) -> Self {
125 let mut presence = Self {
129 exec: false,
130 write_stdin: false,
131 code_search: false,
132 read_file: false,
133 list_files: false,
134 apply_patch: false,
135 request_user_input: false,
136 task_tracker: false,
137 start_planning: false,
138 matrix: false,
139 record_decision: false,
140 };
141 for tool in available_tools {
142 match tool.as_str() {
143 TOOL_EXEC_COMMAND => presence.exec = true,
144 TOOL_WRITE_STDIN => presence.write_stdin = true,
145 TOOL_CODE_SEARCH => presence.code_search = true,
146 TOOL_READ_FILE => presence.read_file = true,
147 TOOL_LIST_FILES => presence.list_files = true,
148 TOOL_APPLY_PATCH => presence.apply_patch = true,
149 TOOL_REQUEST_USER_INPUT => presence.request_user_input = true,
150 TOOL_TASK_TRACKER => presence.task_tracker = true,
151 TOOL_START_PLANNING => presence.start_planning = true,
152 "matrix" => presence.matrix = true,
153 "record_decision" => presence.record_decision = true,
154 _ => {}
155 }
156 }
157 presence
158 }
159}
160
161pub(crate) fn generate_tool_guidelines_with_capabilities(
163 available_tools: &[String],
164 capability_level: Option<CapabilityLevel>,
165 shell_profile: ResolvedShellPromptProfile,
166 profile: ToolGuidanceProfile,
167 parallel_tools: bool,
168) -> String {
169 match profile {
170 ToolGuidanceProfile::Default => {
171 generate_tool_guidelines_for_profile(available_tools, capability_level, shell_profile, parallel_tools)
172 }
173 ToolGuidanceProfile::Minimal => {
174 if available_tools.is_empty() {
175 return String::new();
176 }
177 let presence = ToolPresence::of(available_tools);
178 let mut lines = vec!["\n\n## Active Tools".to_owned()];
179 if presence.matrix {
180 lines.push(MATRIX_GUIDANCE.to_owned());
181 }
182 if presence.record_decision {
183 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
184 }
185 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_owned());
186 if let Some(mode) = capability_mode_line(capability_level, presence.exec, presence.apply_patch) {
187 lines.push(mode.to_owned());
188 }
189 if let Some(browse) = browse_tool_guidance(
190 presence.exec,
191 presence.code_search,
192 presence.list_files,
193 presence.read_file,
194 shell_profile,
195 ) {
196 lines.push(browse);
197 }
198 if presence.code_search {
199 lines.push(CODE_SEARCH_FILTER_LINE.to_owned());
200 }
201 if presence.apply_patch {
202 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());
203 }
204 if presence.exec {
205 lines.push(shell_task_guidance(shell_profile).to_owned());
206 lines.push(background_exec_guidance().to_owned());
207 }
208 if presence.write_stdin {
209 lines.push(format!(
210 "- `write_stdin`: use returned `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; repeat waits after in-progress deadlines{CROSS_TURN_RESUME_HINT_CLAUSE}"
211 ));
212 }
213 if presence.start_planning {
216 lines.push(START_PLANNING_GUIDANCE_LINE.to_owned());
217 }
218 if parallel_tools {
219 lines.push(PARALLEL_TOOLS_LINE.to_owned());
220 }
221 lines.join("\n")
222 }
223 }
224}
225
226pub(crate) fn generate_tool_guidelines_for_profile(
230 available_tools: &[String],
231 capability_level: Option<CapabilityLevel>,
232 shell_profile: ResolvedShellPromptProfile,
233 parallel_tools: bool,
234) -> String {
235 let presence = ToolPresence::of(available_tools);
236 let has_exec = presence.exec;
237 let has_stdin = presence.write_stdin;
238 let has_search = presence.code_search;
239 let has_read_file = presence.read_file;
240 let has_list_files = presence.list_files;
241 let has_apply_patch = presence.apply_patch;
242 let has_start_planning = presence.start_planning;
243
244 let mut lines = Vec::new();
245 if presence.matrix {
246 lines.push(MATRIX_GUIDANCE.to_owned());
247 }
248 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
249 if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_apply_patch) {
250 lines.push(mode_line.to_string());
251 }
252 if let Some(browse_guidance) =
253 browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
254 {
255 lines.push(browse_guidance);
256 }
257 if has_search || has_read_file || has_list_files {
258 lines.push(read_only_batching_guidance(has_read_file).to_string());
259 }
260 if has_apply_patch {
261 lines.push("- Use `apply_patch` for file edits after inspection; keep patches small.".to_string());
262 lines.push(
263 "- Check that each diff stays bounded. WebMCP edits are untrusted proposals, and terminal permission stays authoritative."
264 .to_string(),
265 );
266 }
267 if has_exec {
268 lines.push(shell_task_guidance(shell_profile).to_string());
269 lines.push(background_exec_guidance().to_string());
270 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());
276 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());
282 lines.push("- Run fast checks before full builds.".to_string());
286 }
287 if has_stdin {
293 lines.push(format!(
294 "- `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}"
295 ));
296 }
297 if has_search {
298 lines.push(CODE_SEARCH_FILTER_LINE.to_string());
299 lines.push(code_search_guidance(has_exec));
300 }
301 if has_apply_patch || has_exec {
302 lines.push(
303 "- Build and Auto share tools and safety gates; Auto changes confirmation behavior only after explicit approval or full-auto policy."
304 .to_string(),
305 );
306 }
307 if (has_search || has_exec) && parallel_tools {
308 lines.push(PARALLEL_TOOLS_LINE.to_string());
309 }
310 if has_start_planning {
311 lines.push(START_PLANNING_GUIDANCE_LINE.to_string());
312 }
313
314 if lines.is_empty() {
315 return String::new();
316 }
317
318 if presence.record_decision {
319 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
320 }
321 format!("\n\n## Active Tools\n{}", lines.join("\n"))
322}
323
324pub fn append_runtime_tool_prompt_sections_for_profile(
325 prompt: &mut String,
326 tool_snapshot: &SessionToolCatalogSnapshot,
327 include_catalog_metadata: bool,
328 shell_profile: ResolvedShellPromptProfile,
329) {
330 let names = snapshot_tool_names(tool_snapshot);
331 append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
332}
333
334pub fn append_runtime_tool_prompt_sections_for_model(
336 prompt: &mut String,
337 tool_snapshot: &SessionToolCatalogSnapshot,
338 include_catalog_metadata: bool,
339 shell_profile: ResolvedShellPromptProfile,
340 provider: &dyn crate::llm::provider::LLMProvider,
341 model: &str,
342 config: Option<&crate::config::VTCodeConfig>,
343) {
344 let names = snapshot_tool_names(tool_snapshot);
345 let default_guidance =
346 append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
347 let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
348 let profile = ToolGuidanceProfile::resolve(
349 crate::compaction::effective_context_budget(config, provider, model),
350 vtcode_commons::estimate_tokens(prompt),
351 config.map_or(0, |cfg| cfg.agent.max_system_prompt_tokens as usize),
352 pricing.and_then(|price| price.input),
353 config.and_then(|cfg| cfg.agent.harness.max_budget_usd),
354 );
355 let parallel_tools = provider.supports_parallel_tool_config(model);
356 if tool_snapshot.planning_active && profile == ToolGuidanceProfile::Default {
359 return;
360 }
361 remove_prompt_section(prompt, "## Active Tools");
362 let capability_level = Some(infer_capability_level(&names));
363 let mut guidance = match profile {
364 ToolGuidanceProfile::Default => {
365 let mut guidance = default_guidance;
366 let presence = ToolPresence::of(&names);
367 if let (Some(original), Some(effective)) = (
368 capability_mode_line(None, presence.exec, presence.apply_patch),
369 capability_mode_line(capability_level, presence.exec, presence.apply_patch),
370 ) && original != effective
371 && let Some(start) = guidance.find(original)
372 {
373 guidance.replace_range(start..start + original.len(), effective);
374 }
375 if !parallel_tools && let Some(start) = guidance.find(PARALLEL_TOOLS_LINE) {
376 guidance.replace_range(start.saturating_sub(1)..start + PARALLEL_TOOLS_LINE.len(), "");
377 }
378 guidance
379 }
380 ToolGuidanceProfile::Minimal => {
381 generate_tool_guidelines_with_capabilities(&names, capability_level, shell_profile, profile, parallel_tools)
382 }
383 };
384 if tool_snapshot.planning_active {
385 append_minimal_planning_addendum(&mut guidance, &names);
386 }
387 append_prompt_block(prompt, guidance.trim_start_matches('\n'));
388}
389
390fn append_runtime_tool_sections_with_names(
394 prompt: &mut String,
395 tool_snapshot: &SessionToolCatalogSnapshot,
396 names: &[String],
397 include_catalog_metadata: bool,
398 shell_profile: ResolvedShellPromptProfile,
399) -> String {
400 remove_prompt_section(prompt, "## Active Tools");
401 remove_prompt_section(prompt, "[Runtime Tool Catalog]");
402 while prompt.ends_with('\n') {
403 prompt.pop();
404 }
405
406 let guidelines = generate_runtime_tool_guidelines_for_profile(names, tool_snapshot.planning_active, shell_profile);
407 if !guidelines.is_empty() {
408 append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
409 }
410
411 if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
412 let active_tools = if tool_snapshot.active_tool_names.is_empty() {
413 "none".to_string()
414 } else {
415 tool_snapshot.active_tool_names.join(", ")
416 };
417 let catalog_metadata = format!(
418 "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
419 tool_snapshot.version,
420 tool_snapshot.epoch,
421 tool_snapshot.catalog_tools(),
422 tool_snapshot.available_tools(),
423 active_tools,
424 tool_snapshot.request_user_input_enabled,
425 );
426 append_prompt_block(prompt, &catalog_metadata);
427 }
428 guidelines
429}
430
431fn append_minimal_planning_addendum(guidance: &mut String, names: &[String]) {
434 guidance.push_str("\n- Planning is read-only.");
435 let read_tools = [TOOL_READ_FILE, TOOL_GREP_FILE, TOOL_CODE_SEARCH, TOOL_LIST_FILES]
436 .into_iter()
437 .filter(|tool| names.iter().any(|name| name == tool))
438 .collect::<Vec<_>>();
439 if read_tools.is_empty() {
440 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`.");
441 } else {
442 guidance.push_str(&format!(
443 "\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`.",
444 read_tools.join("`/`")
445 ));
446 }
447 if names.iter().any(|name| name == TOOL_TASK_TRACKER) {
448 guidance.push('\n');
449 guidance.push_str(PLANNING_TASK_TRACKER_COMPACT_LINE);
450 }
451 if names.iter().any(|name| name == TOOL_REQUEST_USER_INPUT) {
452 guidance.push('\n');
453 guidance.push_str(REQUEST_USER_INPUT_LINE);
454 }
455}
456
457pub fn append_deferred_tools_prompt_section(prompt: &mut String, tools: &[ToolDefinition]) {
465 remove_prompt_section(prompt, "[Deferred Tools]");
466
467 let mut groups: Vec<_> = tool_groups(tools)
468 .into_iter()
469 .filter(|group| group.deferred_count > 0)
470 .collect();
471 let overflow = groups.len().saturating_sub(DEFERRED_TOOLS_MAX_GROUPS);
472 groups.truncate(DEFERRED_TOOLS_MAX_GROUPS);
473
474 let mut lines: Vec<String> = groups
475 .into_iter()
476 .map(|group| {
477 let description = truncate_deferred_description(group.description.as_deref().unwrap_or_default());
478 format!("- {} ({} tools): {}", group.name, group.deferred_count, description)
479 })
480 .collect();
481 if overflow > 0 {
482 lines.push(format!("(+{overflow} more deferred groups available — use `search_tools` to find them)"));
483 }
484
485 let unnamespaced_deferred = tools
486 .iter()
487 .filter(|tool| tool.namespace.is_none() && tool.defer_loading == Some(true))
488 .count();
489 if unnamespaced_deferred > 0 {
490 lines.push(format!("- {unnamespaced_deferred} additional deferred tools"));
491 }
492
493 if lines.is_empty() {
494 return;
495 }
496
497 let section = format!(
498 "[Deferred Tools]\n{}\nUse `search_tools` to find a deferred capability. Selected definitions become available in the next request segment.",
499 lines.join("\n")
500 );
501 append_prompt_block(prompt, §ion);
502}
503
504const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
507const DEFERRED_TOOLS_MAX_DESC_CHARS: usize = 120;
510
511fn truncate_deferred_description(description: &str) -> String {
512 if description.chars().count() <= DEFERRED_TOOLS_MAX_DESC_CHARS {
513 return description.to_string();
514 }
515 let truncated: String = description.chars().take(DEFERRED_TOOLS_MAX_DESC_CHARS).collect();
516 format!("{}...", truncated.trim_end())
517}
518
519fn append_prompt_block(prompt: &mut String, block: &str) {
520 if block.is_empty() {
521 return;
522 }
523
524 if prompt.is_empty() {
525 prompt.push_str(block);
526 } else {
527 let _ = write!(prompt, "\n\n{block}");
528 }
529}
530
531fn remove_prompt_section(prompt: &mut String, section_header: &str) {
532 while let Some((section_start, section_end)) = find_prompt_section_bounds(prompt, section_header) {
533 prompt.replace_range(section_start..section_end, "");
534 }
535}
536
537fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
538 crate::prompts::sections::find_prompt_section_bounds(prompt, section_header, SectionBoundaryMode::BracketOrMarkdown)
539}
540
541fn generate_runtime_tool_guidelines_for_profile(
542 available_tools: &[String],
543 planning_active: bool,
544 shell_profile: ResolvedShellPromptProfile,
545) -> String {
546 if !planning_active {
547 return generate_tool_guidelines_for_profile(available_tools, None, shell_profile, true);
551 }
552
553 let presence = ToolPresence::of(available_tools);
554 let has_exec = presence.exec;
555 let has_search = presence.code_search;
556 let has_read_file = presence.read_file;
557 let has_list_files = presence.list_files;
558 let has_request_user_input = presence.request_user_input;
559 let has_task_tracker = presence.task_tracker;
560
561 let mut lines = vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
562 lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
563 if let Some(browse_guidance) =
564 browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
565 {
566 lines.push(browse_guidance);
567 }
568 if has_exec {
569 lines.push("- In Planning workflow, use `exec_command` only for read-only verification.".to_string());
570 }
571 if has_search {
572 lines.push(CODE_SEARCH_FILTER_LINE.to_string());
573 }
574 if has_task_tracker {
575 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());
576 lines.push("- Keep blockers and verification open in `task_tracker` until resolved.".to_string());
577 lines.push(PLANNING_TASK_TRACKER_INDEX_LINE.to_string());
578 }
579 if has_request_user_input {
580 lines.push(REQUEST_USER_INPUT_LINE.to_string());
581 }
582 if has_search || has_exec {
583 lines.push("- If calls repeat without progress, tighten the plan instead of retrying identically.".to_string());
584 }
585 if presence.record_decision {
586 lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
587 }
588
589 format!("\n\n## Active Tools\n{}", lines.join("\n"))
590}
591
592fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
593 tool_snapshot
594 .active_tool_names
595 .iter()
596 .cloned()
597 .collect::<BTreeSet<_>>()
598 .into_iter()
599 .collect()
600}
601
602fn browse_tool_guidance(
603 has_exec: bool,
604 has_search: bool,
605 has_list_files: bool,
606 has_read_file: bool,
607 shell_profile: ResolvedShellPromptProfile,
608) -> Option<String> {
609 if has_exec {
610 return Some(shell_browse_guidance(shell_profile, has_search));
611 }
612
613 if !(has_search || has_list_files || has_read_file) {
614 return None;
615 }
616
617 Some("- Use available read-only repository tools for browsing; do not modify files.".to_string())
618}
619
620pub(crate) fn render_shell_profile_guidance(shell_profile: ResolvedShellPromptProfile) -> String {
621 let profile_lines = match shell_profile {
622 ResolvedShellPromptProfile::UnixLike => {
623 "- 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."
624 }
625 ResolvedShellPromptProfile::PowerShell => {
626 "- 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."
627 }
628 };
629 format!("## Shell Profile\n{profile_lines}\n{SHELL_PROFILE_POLICY_SUFFIX}")
630}
631
632fn shell_browse_guidance(shell_profile: ResolvedShellPromptProfile, has_search: bool) -> String {
633 const SEARCH_PREFERENCE_UNIX: &str = " Prefer `code_search` over `rg`/`grep` for code.";
638 const SEARCH_PREFERENCE_POWERSHELL: &str = " Prefer `code_search` over `Select-String` for code.";
639 match shell_profile {
640 ResolvedShellPromptProfile::UnixLike => {
641 let mut line = if has_search {
642 "- Use `exec_command.cmd` with `ls`, `find`, `cat`, `sed`, and `awk` for repository browsing."
643 .to_string()
644 } else {
645 "- Use `exec_command.cmd` with `ls`, `rg`, `find`, `cat`, `sed`, and `awk` for repository browsing."
646 .to_string()
647 };
648 if has_search {
649 line.push_str(SEARCH_PREFERENCE_UNIX);
650 }
651 line
652 }
653 ResolvedShellPromptProfile::PowerShell => {
654 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();
655 if has_search {
656 line.push_str(SEARCH_PREFERENCE_POWERSHELL);
657 }
658 line
659 }
660 }
661}
662
663fn shell_task_guidance(shell_profile: ResolvedShellPromptProfile) -> &'static str {
664 match shell_profile {
665 ResolvedShellPromptProfile::UnixLike => {
666 "- 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`)."
667 }
668 ResolvedShellPromptProfile::PowerShell => {
669 "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks using native PowerShell syntax."
670 }
671 }
672}
673
674fn background_exec_guidance() -> &'static str {
675 "- 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."
676}
677
678fn read_only_batching_guidance(has_read_file: bool) -> &'static str {
679 if has_read_file {
680 "- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
681 } else {
682 "- Batch independent read-only calls; order dependent reads, and serialize mutations."
683 }
684}
685
686fn code_search_guidance(has_exec: bool) -> String {
687 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/`.";
688 if has_exec {
689 format!("{BASE} Use `exec_command` or a skill for syntax patterns.")
690 } else {
691 BASE.to_string()
692 }
693}
694
695fn capability_mode_line(
696 capability_level: Option<CapabilityLevel>,
697 has_exec: bool,
698 has_file: bool,
699) -> Option<&'static str> {
700 match capability_level {
701 Some(CapabilityLevel::Basic) => {
702 Some("- Capabilities: limited. Ask the user to enable more capabilities if file work is required.")
703 }
704 Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(CAPABILITY_READ_ONLY_LINE),
705 _ if !has_exec && !has_file => Some(CAPABILITY_READ_ONLY_LINE),
706 _ => None,
707 }
708}
709
710pub(crate) fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
712 let presence = ToolPresence::of(available_tools);
713 if presence.code_search {
714 CapabilityLevel::CodeSearch
715 } else if presence.apply_patch {
716 CapabilityLevel::Editing
717 } else if presence.exec {
718 CapabilityLevel::Bash
719 } else if presence.list_files {
720 CapabilityLevel::FileListing
721 } else if presence.read_file {
722 CapabilityLevel::FileReading
723 } else {
724 CapabilityLevel::Basic
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::config::types::ShellPromptProfile;
732
733 fn guidelines_for(tools: &[String], capability_level: Option<CapabilityLevel>) -> String {
736 generate_tool_guidelines_for_profile(
737 tools,
738 capability_level,
739 ShellPromptProfile::Auto.resolve_for_current_platform(),
740 true,
741 )
742 }
743
744 fn rebuilt_model_tool_prompt(
747 mut prompt: String,
748 snapshot: &SessionToolCatalogSnapshot,
749 metadata: bool,
750 shell: ResolvedShellPromptProfile,
751 provider: &dyn crate::llm::provider::LLMProvider,
752 model: &str,
753 config: &crate::config::VTCodeConfig,
754 ) -> String {
755 append_runtime_tool_prompt_sections_for_profile(&mut prompt, snapshot, metadata, shell);
756 let names = snapshot_tool_names(snapshot);
757 let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
758 let profile = ToolGuidanceProfile::resolve(
759 crate::compaction::effective_context_budget(Some(config), provider, model),
760 vtcode_commons::estimate_tokens(&prompt),
761 config.agent.max_system_prompt_tokens as usize,
762 pricing.and_then(|price| price.input),
763 config.agent.harness.max_budget_usd,
764 );
765 if snapshot.planning_active && profile == ToolGuidanceProfile::Default {
766 return prompt;
767 }
768 remove_prompt_section(&mut prompt, "## Active Tools");
769 let mut guidance = generate_tool_guidelines_with_capabilities(
770 &names,
771 Some(infer_capability_level(&names)),
772 shell,
773 profile,
774 provider.supports_parallel_tool_config(model),
775 );
776 if snapshot.planning_active {
777 append_minimal_planning_addendum(&mut guidance, &names);
778 }
779 append_prompt_block(&mut prompt, guidance.trim_start_matches('\n'));
780 prompt
781 }
782
783 #[test]
784 fn model_tool_prompt_reuse_matches_rebuild_across_density_and_capabilities() {
785 use crate::config::constants::models;
786 use crate::llm::provider::LLMProvider;
787 use crate::llm::providers::{AnthropicProvider, HuggingFaceProvider};
788
789 struct ProviderCase {
790 provider: Box<dyn LLMProvider>,
791 model: &'static str,
792 parallel: bool,
793 }
794 let providers = [
795 ProviderCase {
796 provider: Box::new(AnthropicProvider::new("offline-fixture".into())),
797 model: models::anthropic::DEFAULT_MODEL,
798 parallel: true,
799 },
800 ProviderCase {
801 provider: Box::new(HuggingFaceProvider::new("offline-fixture".into())),
802 model: "offline-fixture",
803 parallel: false,
804 },
805 ];
806 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";
807 for case in providers {
808 assert_eq!(case.provider.supports_parallel_tool_config(case.model), case.parallel);
809 for names in [
810 None,
811 Some(&[][..]),
812 Some(&[TOOL_TASK_TRACKER, TOOL_REQUEST_USER_INPUT][..]),
813 Some(&[TOOL_READ_FILE][..]),
814 Some(&[TOOL_WRITE_STDIN][..]),
815 Some(&[TOOL_EXEC_COMMAND, TOOL_APPLY_PATCH, TOOL_CODE_SEARCH, "record_decision"][..]),
816 ] {
817 for planning in [false, true] {
818 let snapshot = SessionToolCatalogSnapshot::new(
819 7,
820 9,
821 planning,
822 true,
823 names.map(|names| {
824 std::sync::Arc::new(
825 names
826 .iter()
827 .map(|name| {
828 ToolDefinition::function(
829 (*name).to_string(),
830 "fixture".to_string(),
831 serde_json::json!({"type": "object"}),
832 )
833 })
834 .collect(),
835 )
836 }),
837 false,
838 );
839 for shell in [
840 ResolvedShellPromptProfile::UnixLike,
841 ResolvedShellPromptProfile::PowerShell,
842 ] {
843 for metadata in [false, true] {
844 let mut measured = initial.to_string();
845 append_runtime_tool_prompt_sections_for_profile(&mut measured, &snapshot, metadata, shell);
846 let measured_tokens = vtcode_commons::estimate_tokens(&measured) as u64;
847 for budget in [1, measured_tokens - 1, measured_tokens, 100_000] {
848 let mut config = crate::config::VTCodeConfig::default();
849 config.context.max_context_tokens = 128_000;
850 config.agent.max_system_prompt_tokens = budget;
851 let expected = rebuilt_model_tool_prompt(
852 initial.to_string(),
853 &snapshot,
854 metadata,
855 shell,
856 case.provider.as_ref(),
857 case.model,
858 &config,
859 );
860 let mut actual = initial.to_string();
861 append_runtime_tool_prompt_sections_for_model(
862 &mut actual,
863 &snapshot,
864 metadata,
865 shell,
866 case.provider.as_ref(),
867 case.model,
868 Some(&config),
869 );
870
871 assert_eq!(
872 actual,
873 expected,
874 "provider={}, names={names:?}, planning={planning}, shell={shell:?}, metadata={metadata}, budget={budget}",
875 case.provider.name()
876 );
877 assert_eq!(
878 actual.matches("## Active Tools").count(),
879 usize::from(
880 budget >= measured_tokens || names.is_some_and(|names| !names.is_empty())
881 )
882 );
883 assert!(
884 actual.contains("# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.")
885 );
886 assert!(actual.contains("## Tail\nKeep this section."));
887 }
888 }
889 }
890 }
891 }
892 }
893 }
894
895 #[test]
896 fn matrix_guidance_has_presence_and_budget_in_both_densities() {
897 for profile in [ToolGuidanceProfile::Default, ToolGuidanceProfile::Minimal] {
898 let enabled = generate_tool_guidelines_with_capabilities(
899 &["matrix".to_string(), "request_user_input".to_string()],
900 None,
901 ResolvedShellPromptProfile::UnixLike,
902 profile,
903 false,
904 );
905 let disabled = generate_tool_guidelines_with_capabilities(
906 &["request_user_input".to_string()],
907 None,
908 ResolvedShellPromptProfile::UnixLike,
909 profile,
910 false,
911 );
912 assert_eq!(enabled.matches(MATRIX_GUIDANCE).count(), 1);
913 assert!(enabled.contains("declared input changes"));
914 assert!(enabled.contains("effective permissions and sandbox gates"));
915 assert!(enabled.contains("Replay restores admission before discovery"));
916 assert!(enabled.contains("user cancel is terminal"));
917 assert!(!disabled.contains("scheduler-owned"));
918 assert!(vtcode_commons::estimate_tokens(&enabled) <= 220, "{profile:?}: {enabled}");
919 }
920 }
921
922 #[test]
926 fn universal_rules_have_one_home_across_composed_prompt_sections() {
927 use crate::config::types::SystemPromptMode;
928 use crate::prompts::harness_limits::upsert_harness_limits_section;
929 use crate::prompts::static_prompts::static_profile_prompt;
930 use crate::prompts::system::PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE;
931
932 let shell = ResolvedShellPromptProfile::UnixLike;
933 let execution_tools = [
934 TOOL_EXEC_COMMAND,
935 TOOL_WRITE_STDIN,
936 TOOL_APPLY_PATCH,
937 TOOL_CODE_SEARCH,
938 TOOL_TASK_TRACKER,
939 TOOL_START_PLANNING,
940 TOOL_REQUEST_USER_INPUT,
941 ]
942 .map(str::to_owned)
943 .to_vec();
944 let planning_tools = [
945 TOOL_EXEC_COMMAND,
946 TOOL_WRITE_STDIN,
947 TOOL_CODE_SEARCH,
948 TOOL_GREP_FILE,
949 TOOL_TASK_TRACKER,
950 TOOL_REQUEST_USER_INPUT,
951 ]
952 .map(str::to_owned)
953 .to_vec();
954 let mut minimal_planning = generate_tool_guidelines_with_capabilities(
955 &planning_tools,
956 None,
957 shell,
958 ToolGuidanceProfile::Minimal,
959 false,
960 );
961 append_minimal_planning_addendum(&mut minimal_planning, &planning_tools);
962 let variants = [
963 ("default", generate_tool_guidelines_for_profile(&execution_tools, None, shell, true), false),
964 (
965 "minimal",
966 generate_tool_guidelines_with_capabilities(
967 &execution_tools,
968 None,
969 shell,
970 ToolGuidanceProfile::Minimal,
971 true,
972 ),
973 false,
974 ),
975 ("default planning", generate_runtime_tool_guidelines_for_profile(&planning_tools, true, shell), true),
976 ("minimal planning", minimal_planning, true),
977 ];
978 let markers = [
981 "never claim a check passed",
982 "Diagnose failures; change approach",
983 "Use returned `next_wait_args`",
984 "Treat empty searches as evidence",
985 "Check optional tools once",
986 "small non-overlapping ranges",
987 "accumulated output never exhausts tool access",
988 "additional_permissions",
989 "bypass safeguards",
990 "Delegate only sizeable",
991 "Across compaction",
992 ];
993
994 for mode in [
995 SystemPromptMode::Default,
996 SystemPromptMode::Minimal,
997 SystemPromptMode::Lightweight,
998 SystemPromptMode::Specialized,
999 ] {
1000 for (variant, guidance, planning) in &variants {
1001 let mut prompt = static_profile_prompt(mode).to_owned();
1002 if *planning {
1003 prompt.push_str("\n\n");
1004 prompt.push_str(PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE);
1005 }
1006 prompt.push_str(guidance);
1007 upsert_harness_limits_section(&mut prompt, 32, 600, 2);
1008 for marker in markers {
1009 assert_eq!(
1010 prompt.matches(marker).count(),
1011 1,
1012 "{mode:?} with {variant} tool guidance should state {marker:?} exactly once"
1013 );
1014 }
1015 let start_planning_mentions = prompt.matches("start_planning").count();
1016 assert_eq!(start_planning_mentions, usize::from(!*planning), "{mode:?} with {variant} tool guidance");
1017 }
1018 }
1019 }
1020
1021 #[test]
1022 fn documentation_profile_respects_context_tokens_and_known_cost() {
1023 assert_eq!(ToolGuidanceProfile::resolve(32_000, 100, 1000, None, None), ToolGuidanceProfile::Minimal);
1024 assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 100, 1000, None, Some(0.0)), ToolGuidanceProfile::Default);
1025 assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 1001, 1000, None, None), ToolGuidanceProfile::Minimal);
1026 assert_eq!(
1027 ToolGuidanceProfile::resolve(1_000_000, 1000, 2000, Some(0.00001), Some(0.001)),
1028 ToolGuidanceProfile::Minimal
1029 );
1030 }
1031
1032 #[test]
1033 fn minimal_and_default_tool_guidance_snapshots() {
1034 let tools = vec![TOOL_READ_FILE.to_owned()];
1035 let minimal = generate_tool_guidelines_with_capabilities(
1036 &tools,
1037 None,
1038 ResolvedShellPromptProfile::UnixLike,
1039 ToolGuidanceProfile::Minimal,
1040 false,
1041 );
1042 assert_eq!(
1043 minimal,
1044 "\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."
1045 );
1046 let default = generate_tool_guidelines_with_capabilities(
1047 &tools,
1048 None,
1049 ResolvedShellPromptProfile::UnixLike,
1050 ToolGuidanceProfile::Default,
1051 false,
1052 );
1053 assert_eq!(
1054 default,
1055 "\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`."
1056 );
1057 }
1058
1059 #[test]
1060 fn tool_presence_flags_record_decision_in_the_single_pass() {
1061 let with = ToolPresence::of(&["matrix".to_string(), "record_decision".to_string()]);
1062 assert!(with.matrix);
1063 assert!(with.record_decision);
1064 let without = ToolPresence::of(&["matrix".to_string()]);
1065 assert!(without.matrix);
1066 assert!(!without.record_decision);
1067 }
1068
1069 #[test]
1070 fn public_decision_guidance_is_optional_and_bounded() {
1071 for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1072 let enabled = generate_tool_guidelines_with_capabilities(
1073 &[TOOL_READ_FILE.into(), "record_decision".into()],
1074 None,
1075 ResolvedShellPromptProfile::UnixLike,
1076 profile,
1077 false,
1078 );
1079 let disabled = generate_tool_guidelines_with_capabilities(
1080 &[TOOL_READ_FILE.into()],
1081 None,
1082 ResolvedShellPromptProfile::UnixLike,
1083 profile,
1084 false,
1085 );
1086 assert!(enabled.contains(PUBLIC_DECISION_GUIDANCE));
1087 assert!(!disabled.contains("record_decision"));
1088 assert!(enabled.len().saturating_sub(disabled.len()) < 300);
1089 assert!(!enabled.contains("chain of thought"));
1090 }
1091 }
1092
1093 #[test]
1094 fn tool_guidance_uses_actual_parallel_capabilities_for_three_families() {
1095 use crate::config::constants::models;
1096 use crate::llm::provider::LLMProvider;
1097 use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
1098 let providers: [(Box<dyn LLMProvider>, &str); 3] = [
1099 (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
1100 (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
1101 (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
1102 ];
1103 for (provider, model) in providers {
1104 for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1105 let parallel = provider.supports_parallel_tool_config(model);
1106 let text = generate_tool_guidelines_with_capabilities(
1107 &[TOOL_EXEC_COMMAND.to_owned()],
1108 None,
1109 ResolvedShellPromptProfile::UnixLike,
1110 profile,
1111 parallel,
1112 );
1113 assert_eq!(text.contains("tools in parallel"), parallel);
1114 let serial = generate_tool_guidelines_with_capabilities(
1115 &[TOOL_EXEC_COMMAND.to_owned()],
1116 None,
1117 ResolvedShellPromptProfile::UnixLike,
1118 profile,
1119 false,
1120 );
1121 assert!(!serial.contains("tools in parallel"));
1122 }
1123 }
1124 }
1125
1126 #[test]
1127 fn test_read_only_capability_detection() {
1128 let tools = vec![TOOL_CODE_SEARCH.to_string()];
1129 let guidelines = guidelines_for(&tools, None);
1130 assert!(guidelines.contains("Capabilities: read-only"));
1131 assert!(guidelines.contains("do not modify files"));
1132 }
1133
1134 #[test]
1135 fn test_tool_preference_guidance() {
1136 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_CODE_SEARCH.to_string()];
1137 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1138 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1139 assert!(guidelines.contains("literal smart-case"));
1140 assert!(guidelines.contains("exact syntactic usages"));
1141 assert!(guidelines.contains("\"result_types\":[\"definition\"]"));
1142 assert!(guidelines.contains("Do not JSON-encode arrays or integers as strings"));
1143 assert!(guidelines.contains("omit unused filters"));
1144 assert!(guidelines.contains("path: \"\""));
1145 assert!(guidelines.contains("git diff -- <path>"));
1146 assert!(guidelines.contains("build tools"));
1147 assert!(guidelines.contains("test tools"));
1148 assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1152 assert!(guidelines.contains("Run fast checks before full builds"));
1154 assert!(!guidelines.contains("Completion is a checkpoint"));
1157 }
1158
1159 #[test]
1160 fn test_edit_workflow_guidance() {
1161 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1162 let guidelines = guidelines_for(&tools, None);
1163 assert!(guidelines.contains("Use `apply_patch`"));
1164 assert!(guidelines.contains("patches small"));
1165 assert!(!guidelines.contains("verification resolved"));
1168 }
1169
1170 #[test]
1171 fn test_vt_code_guidance_omits_task_tracker() {
1172 let tools = vec![
1173 TOOL_EXEC_COMMAND.to_string(),
1174 TOOL_WRITE_STDIN.to_string(),
1175 TOOL_APPLY_PATCH.to_string(),
1176 ];
1177 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1178
1179 assert!(guidelines.contains("exec_command.cmd"));
1180 for command in ["ls", "rg", "find", "cat", "sed", "awk"] {
1181 assert!(
1182 guidelines.contains(&format!("`{command}`")),
1183 "{command} should be shown as an exec_command.cmd example"
1184 );
1185 }
1186 assert!(guidelines.contains("`write_stdin`"));
1187 assert!(guidelines.contains("At most three live background processes"));
1188 assert!(guidelines.contains("`apply_patch`"));
1189 assert!(!guidelines.contains("task_tracker"));
1190 assert!(!guidelines.contains("list_files"));
1191 assert!(!guidelines.contains("read_file"));
1192 }
1193
1194 #[test]
1195 fn task_tracker_guidance_explains_action_aware_indices() {
1196 let guidelines = generate_runtime_tool_guidelines_for_profile(
1197 &[TOOL_TASK_TRACKER.to_string()],
1198 true,
1199 ResolvedShellPromptProfile::UnixLike,
1200 );
1201
1202 assert!(guidelines.contains(&format!("\n{PLANNING_TASK_TRACKER_INDEX_LINE}")));
1203 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("positive flat indices"));
1204 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("(index 0 is invalid while planning)"));
1205 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("full checklist replacement"));
1206 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"list"}"#));
1207 assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"update","index":1,"status":"in_progress"}"#));
1208 assert!(
1209 PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"update","items":["[x] Done","[ ] Remaining"]}"#)
1210 );
1211 assert!(PLANNING_TASK_TRACKER_COMPACT_LINE.contains("never conflicting fields"));
1212 for line in [PLANNING_TASK_TRACKER_INDEX_LINE, PLANNING_TASK_TRACKER_COMPACT_LINE] {
1215 assert!(line.contains("index 0 is invalid while planning"), "{line}");
1216 assert!(!line.contains("reserved"), "{line}");
1217 assert!(!line.contains("index: 0"), "{line}");
1218 }
1219 }
1220
1221 #[test]
1222 fn unix_like_guidance_makes_command_reuse_explicit() {
1223 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_WRITE_STDIN.to_string()];
1224 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1225
1226 assert!(guidelines.contains("one-shot `exec_command` calls"));
1227 assert!(guidelines.contains("`!!`, `!$`, `!ssh`, or `fc`"));
1228 assert!(guidelines.contains("write full command arguments explicitly"));
1229 assert!(guidelines.contains("conversation or tool results"));
1230 assert!(guidelines.contains("existing `session_id`"));
1231 assert!(guidelines.contains("background: true"));
1232 assert!(guidelines.contains("Bash `histverify`"));
1233 assert!(guidelines.contains("zsh `HIST_VERIFY`"));
1234 assert!(guidelines.contains("`Exec session resume:`"));
1237 assert!(guidelines.contains("prior turn ended mid-run"));
1238 assert!(!guidelines.contains("Prefer `code_search` over `rg`"));
1241 }
1242
1243 #[test]
1244 fn write_stdin_guidance_advertises_cross_turn_resume_hint() {
1245 let tools = vec![TOOL_WRITE_STDIN.to_string()];
1246 let default_guidance =
1247 generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1248 assert!(default_guidance.contains("`Exec session resume:`"));
1249 assert!(default_guidance.contains("prior turn ended mid-run"));
1250 assert!(default_guidance.contains("if missing, recover prior output"));
1251 assert!(default_guidance.contains("rerun only for fresh results"));
1252
1253 let minimal = generate_tool_guidelines_with_capabilities(
1254 &tools,
1255 None,
1256 ResolvedShellPromptProfile::UnixLike,
1257 ToolGuidanceProfile::Minimal,
1258 false,
1259 );
1260 assert!(minimal.contains("`Exec session resume:`"));
1261 assert!(minimal.contains("prior turn ended mid-run"));
1262 assert!(minimal.contains("if missing, recover prior output"));
1263 }
1264
1265 #[test]
1266 fn powershell_guidance_uses_native_command_examples() {
1267 let tools = vec![
1268 TOOL_EXEC_COMMAND.to_string(),
1269 TOOL_CODE_SEARCH.to_string(),
1270 TOOL_APPLY_PATCH.to_string(),
1271 ];
1272 let guidelines =
1273 generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::PowerShell, true);
1274
1275 assert!(guidelines.contains("native PowerShell commands"));
1276 assert!(guidelines.contains("`Get-ChildItem`"));
1277 assert!(guidelines.contains("`Select-String`"));
1278 assert!(guidelines.contains("native PowerShell syntax"));
1279 assert!(guidelines.contains("Prefer `code_search` over `Select-String` for code"));
1280 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1281 assert!(guidelines.contains("literal smart-case"));
1282 assert!(guidelines.contains("omit unused filters"));
1283 assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1284 assert!(!guidelines.contains("shell history expansion"));
1285 assert!(!guidelines.contains("histverify"));
1286 assert!(!guidelines.contains("HIST_VERIFY"));
1287 }
1288
1289 #[test]
1290 fn shell_profile_prompt_keeps_policy_and_syntax_separate() {
1291 let unix = render_shell_profile_guidance(ResolvedShellPromptProfile::UnixLike);
1292 assert!(unix.contains("Active shell profile: `unix_like`"));
1293 assert!(unix.contains("does not rewrite GNU flags for macOS BSD tools"));
1294 assert!(unix.contains("controls prompt examples and expected command syntax only"));
1295 assert!(unix.contains("does not translate GNU-to-BSD"));
1296
1297 let powershell = render_shell_profile_guidance(ResolvedShellPromptProfile::PowerShell);
1298 assert!(powershell.contains("Active shell profile: `powershell`"));
1299 assert!(powershell.contains("WSL"));
1300 assert!(powershell.contains("Unix-like workflows"));
1301 assert!(powershell.contains("PowerShell-to-Unix"));
1302 }
1303
1304 #[test]
1305 fn test_harness_browse_tool_guidance() {
1306 let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
1307 let guidelines = guidelines_for(&tools, None);
1308 assert!(guidelines.contains("available read-only repository tools"));
1309 assert!(guidelines.contains("bounded `read_file` ranges"));
1310 assert!(!guidelines.contains("list_files"));
1311 assert!(!guidelines.contains("offset"));
1312 assert!(!guidelines.contains("per_page"));
1313 }
1314
1315 #[test]
1316 fn test_canonical_browse_tool_guidance_prefers_public_tools() {
1317 let tools = vec![
1318 TOOL_CODE_SEARCH.to_string(),
1319 TOOL_LIST_FILES.to_string(),
1320 "read_file".to_string(),
1321 ];
1322 let guidelines = guidelines_for(&tools, None);
1323 assert!(guidelines.contains("available read-only repository tools"));
1324 assert!(guidelines.contains("code_search"));
1325 assert!(guidelines.contains("bounded `read_file` ranges"));
1326 }
1327
1328 #[test]
1329 fn test_capability_basic_guidance() {
1330 let tools = vec![];
1331 let guidelines = guidelines_for(&tools, Some(CapabilityLevel::Basic));
1332 assert!(guidelines.contains("Capabilities: limited"));
1333 assert!(guidelines.contains("enable more capabilities"));
1334 }
1335
1336 #[test]
1337 fn test_capability_file_reading_guidance() {
1338 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1339 let guidelines = guidelines_for(&tools, Some(CapabilityLevel::FileReading));
1340 assert!(guidelines.contains("Capabilities: read-only"));
1341 assert!(guidelines.contains("do not modify"));
1342 }
1343
1344 #[test]
1345 fn test_full_capabilities_no_special_guidance() {
1346 let tools = vec![
1347 TOOL_APPLY_PATCH.to_string(),
1348 TOOL_EXEC_COMMAND.to_string(),
1349 TOOL_CODE_SEARCH.to_string(),
1350 ];
1351 let guidelines = generate_tool_guidelines_for_profile(
1352 &tools,
1353 Some(CapabilityLevel::Editing),
1354 ResolvedShellPromptProfile::UnixLike,
1355 true,
1356 );
1357
1358 assert!(!guidelines.contains("Capabilities: limited"));
1359 assert!(!guidelines.contains("Capabilities: read-only"));
1360 }
1361
1362 #[test]
1363 fn test_empty_tools_shows_read_only_capabilities() {
1364 let tools = vec![];
1365 let guidelines = guidelines_for(&tools, None);
1366 assert!(guidelines.contains("Capabilities: read-only"));
1367 }
1368
1369 #[test]
1370 fn test_planning_workflow_guidance_keeps_verification_open() {
1371 let tools = vec![
1372 TOOL_EXEC_COMMAND.to_string(),
1373 TOOL_TASK_TRACKER.to_string(),
1374 TOOL_CODE_SEARCH.to_string(),
1375 ];
1376 let guidelines =
1377 generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1378 assert!(guidelines.contains("Keep `task_tracker` updated"));
1379 assert!(guidelines.contains("blockers and verification open"));
1380 }
1381
1382 #[test]
1383 fn tool_presence_of_sets_each_flag_in_one_pass() {
1384 let tools = vec![
1385 TOOL_EXEC_COMMAND.to_string(),
1386 TOOL_WRITE_STDIN.to_string(),
1387 TOOL_CODE_SEARCH.to_string(),
1388 TOOL_READ_FILE.to_string(),
1389 TOOL_LIST_FILES.to_string(),
1390 TOOL_APPLY_PATCH.to_string(),
1391 TOOL_REQUEST_USER_INPUT.to_string(),
1392 TOOL_TASK_TRACKER.to_string(),
1393 TOOL_START_PLANNING.to_string(),
1394 "matrix".to_string(),
1395 "unknown_tool".to_string(),
1396 ];
1397 let presence = ToolPresence::of(&tools);
1398 assert!(presence.exec);
1399 assert!(presence.write_stdin);
1400 assert!(presence.code_search);
1401 assert!(presence.read_file);
1402 assert!(presence.list_files);
1403 assert!(presence.apply_patch);
1404 assert!(presence.request_user_input);
1405 assert!(presence.task_tracker);
1406 assert!(presence.start_planning);
1407 assert!(presence.matrix);
1408
1409 let empty = ToolPresence::of(&[]);
1410 assert!(!empty.exec);
1411 assert!(!empty.write_stdin);
1412 assert!(!empty.code_search);
1413 assert!(!empty.read_file);
1414 assert!(!empty.list_files);
1415 assert!(!empty.apply_patch);
1416 assert!(!empty.request_user_input);
1417 assert!(!empty.task_tracker);
1418 assert!(!empty.start_planning);
1419 assert!(!empty.matrix);
1420 }
1421
1422 #[test]
1423 fn test_capability_inference_precedence() {
1424 let tools = vec![TOOL_APPLY_PATCH.to_string(), TOOL_CODE_SEARCH.to_string()];
1425 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1426
1427 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_APPLY_PATCH.to_string()];
1428 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1429 }
1430
1431 #[test]
1432 fn test_capability_inference_variants() {
1433 let tools = vec![TOOL_APPLY_PATCH.to_string()];
1434 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1435
1436 let tools = vec![TOOL_EXEC_COMMAND.to_string()];
1437 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
1438
1439 let tools = vec![TOOL_CODE_SEARCH.to_string()];
1440 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1441
1442 let tools = vec![TOOL_LIST_FILES.to_string()];
1443 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
1444
1445 let tools = vec!["read_file".to_string()];
1446 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
1447
1448 let tools = vec!["unknown_tool".to_string()];
1449 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
1450 }
1451
1452 #[test]
1453 fn test_guidelines_stay_compact() {
1454 let tools = vec![
1455 TOOL_EXEC_COMMAND.to_string(),
1456 TOOL_CODE_SEARCH.to_string(),
1457 "read_file".to_string(),
1458 TOOL_LIST_FILES.to_string(),
1459 "apply_patch".to_string(),
1460 ];
1461 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1462 assert!(guidelines.contains("Batch independent read-only calls"));
1463 assert!(guidelines.contains("code_search"));
1464 assert!(guidelines.contains("static read-only filtering pipelines use fail-closed `pipefail`"));
1467 assert!(guidelines.contains("Only terminal exit 0 clears verification"));
1468 assert!(guidelines.contains("dynamic syntax, mutating tails, `;`, and `||` do not qualify"));
1469 assert!(!guidelines.contains("elided"));
1470 assert!(guidelines.contains("max_output_tokens"));
1471 assert!(guidelines.contains("Build and Auto share tools and safety gates"));
1472 let approx_tokens = vtcode_commons::estimate_tokens(&guidelines);
1473 assert!(approx_tokens < 580, "got ~{approx_tokens} tokens");
1480 }
1481
1482 #[test]
1483 fn deferred_tools_section_caps_groups_and_truncates_descriptions() {
1484 use crate::llm::provider::ToolNamespace;
1485
1486 fn deferred_mcp_tool(server: &str, tool: &str, description: &str) -> ToolDefinition {
1487 let mut definition = ToolDefinition::function(
1488 format!("mcp__{server}__{tool}"),
1489 "deferred".to_string(),
1490 serde_json::json!({"type": "object"}),
1491 );
1492 definition.namespace = Some(ToolNamespace {
1493 name: server.to_string(),
1494 description: description.to_string(),
1495 });
1496 definition.defer_loading = Some(true);
1497 definition
1498 }
1499
1500 let mut tools = Vec::new();
1501 for index in 0..10 {
1502 tools.push(deferred_mcp_tool(&format!("server-{index:02}"), "search", "Tools provided by MCP server"));
1503 }
1504 tools.push(deferred_mcp_tool("server-long", "search", &"d".repeat(500)));
1506
1507 let mut prompt = "Base prompt".to_string();
1508 append_deferred_tools_prompt_section(&mut prompt, &tools);
1509
1510 assert!(prompt.contains("[Deferred Tools]"));
1511 assert!(prompt.contains("server-00 (1 tools)"));
1512 assert!(prompt.contains("server-07 (1 tools)"));
1513 assert!(!prompt.contains("server-08 (1 tools)"), "groups past the cap must collapse into overflow");
1514 assert!(!prompt.contains("server-09 (1 tools)"));
1515 assert!(
1516 prompt.contains("(+3 more deferred groups available"),
1517 "10 named groups + 1 long group over the 8-group cap overflows by 3"
1518 );
1519 assert!(!prompt.contains(&"d".repeat(121)), "group descriptions must stay truncated");
1520 assert!(prompt.contains("Use `search_tools` to find a deferred capability"));
1521
1522 append_deferred_tools_prompt_section(&mut prompt, &tools);
1524 assert_eq!(prompt.matches("[Deferred Tools]").count(), 1);
1525 }
1526
1527 #[test]
1528 fn deferred_tools_section_omits_empty_and_fully_loaded_groups() {
1529 let mut prompt = "Base prompt".to_string();
1530 append_deferred_tools_prompt_section(&mut prompt, &[]);
1531 assert!(!prompt.contains("[Deferred Tools]"));
1532
1533 let loaded = ToolDefinition::function(
1534 "exec_command".to_string(),
1535 "Shell".to_string(),
1536 serde_json::json!({"type": "object"}),
1537 );
1538 let mut prompt = "Base prompt".to_string();
1539 append_deferred_tools_prompt_section(&mut prompt, &[loaded]);
1540 assert!(!prompt.contains("[Deferred Tools]"));
1541 }
1542
1543 #[test]
1544 fn test_parallel_tool_call_guidance() {
1545 let tools = vec![
1546 TOOL_EXEC_COMMAND.to_string(),
1547 TOOL_CODE_SEARCH.to_string(),
1548 TOOL_APPLY_PATCH.to_string(),
1549 ];
1550 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1551 assert!(guidelines.contains("parallel"), "Should include parallel tool call guidance");
1552 assert!(guidelines.contains("inputs do not depend"), "Should mention independent inputs");
1553 }
1554
1555 #[test]
1556 fn test_read_only_batching_guidance_is_explicit() {
1557 let tools = vec![
1558 TOOL_CODE_SEARCH.to_string(),
1559 TOOL_READ_FILE.to_string(),
1560 TOOL_LIST_FILES.to_string(),
1561 ];
1562 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1563
1564 assert!(guidelines.contains("Batch independent read-only calls"));
1565 assert!(guidelines.contains("`read_file` ranges"));
1566 assert!(guidelines.contains("serialize mutations"));
1567 assert!(!guidelines.contains("Only terminal exit 0 clears verification"));
1570 }
1571
1572 #[test]
1573 fn execution_agents_can_suggest_planning_for_demanding_tasks() {
1574 let tools = vec![TOOL_START_PLANNING.to_string(), TOOL_EXEC_COMMAND.to_string()];
1575 let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1576
1577 assert_eq!(guidelines.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1578 let minimal = generate_tool_guidelines_with_capabilities(
1579 &tools,
1580 None,
1581 ResolvedShellPromptProfile::UnixLike,
1582 ToolGuidanceProfile::Minimal,
1583 false,
1584 );
1585 assert_eq!(minimal.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1586 let without = generate_tool_guidelines_for_profile(
1588 &[TOOL_EXEC_COMMAND.to_string()],
1589 None,
1590 ResolvedShellPromptProfile::UnixLike,
1591 true,
1592 );
1593 assert!(!without.contains("start_planning"));
1594 }
1595
1596 #[test]
1597 fn markdown_validation_is_optional_in_planning_and_execution() {
1598 let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_READ_FILE.to_string()];
1599 for planning in [false, true] {
1600 let guidance =
1601 generate_runtime_tool_guidelines_for_profile(&tools, planning, ResolvedShellPromptProfile::UnixLike);
1602 assert_eq!(guidance.matches(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE).count(), 1);
1603 assert!(guidance.contains("report skipped"));
1604 assert!(guidance.contains("without installing tools"));
1605 assert!(guidance.contains("Lint errors remain failures"));
1606 }
1607 }
1608
1609 #[test]
1610 fn planning_workflow_runtime_guidance_keeps_exec_read_only() {
1611 let tools = vec![
1612 TOOL_APPLY_PATCH.to_string(),
1613 TOOL_EXEC_COMMAND.to_string(),
1614 TOOL_CODE_SEARCH.to_string(),
1615 ];
1616 let guidelines =
1617 generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1618
1619 assert!(guidelines.contains("Planning workflow active"));
1620 assert!(guidelines.contains("`exec_command` only for read-only verification"));
1621 assert!(!guidelines.contains("<proposed_plan>"));
1622 assert!(!guidelines.contains("Every implementation step"));
1623 assert!(guidelines.contains("omit unused filters"));
1624 assert!(!guidelines.contains("Inspect before edit"));
1625 }
1626
1627 #[test]
1628 fn runtime_tool_guidance_uses_explicit_powershell_profile() {
1629 let tools = vec![
1630 TOOL_APPLY_PATCH.to_string(),
1631 TOOL_EXEC_COMMAND.to_string(),
1632 TOOL_CODE_SEARCH.to_string(),
1633 ];
1634 let guidelines =
1635 generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::PowerShell);
1636
1637 assert!(guidelines.contains("native PowerShell commands"));
1638 assert!(guidelines.contains("`Get-ChildItem`"));
1639 assert!(guidelines.contains("`Select-String`"));
1640 assert!(guidelines.contains("native PowerShell syntax"));
1641 assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1642 }
1643
1644 #[test]
1645 fn runtime_tool_guidance_uses_explicit_unix_like_profile() {
1646 let tools = vec![
1647 TOOL_APPLY_PATCH.to_string(),
1648 TOOL_EXEC_COMMAND.to_string(),
1649 TOOL_CODE_SEARCH.to_string(),
1650 ];
1651 let guidelines =
1652 generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::UnixLike);
1653
1654 assert!(guidelines.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1655 assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1656 assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1657 assert!(guidelines.contains("literal smart-case"));
1658 assert!(guidelines.contains("shell-only tasks"));
1659 assert!(!guidelines.contains("native PowerShell commands"));
1660 assert!(!guidelines.contains("`Get-ChildItem`"));
1661 }
1662
1663 #[test]
1664 fn runtime_tool_prompt_sections_use_explicit_profile_for_active_tools() {
1665 let mut powershell_prompt = "Base prompt".to_string();
1666 let mut unix_prompt = "Base prompt".to_string();
1667 let snapshot = SessionToolCatalogSnapshot::new(
1668 7,
1669 9,
1670 false,
1671 false,
1672 Some(std::sync::Arc::new(vec![
1673 ToolDefinition::function(
1674 TOOL_EXEC_COMMAND.to_string(),
1675 "Shell".to_string(),
1676 serde_json::json!({"type": "object"}),
1677 ),
1678 ToolDefinition::function(
1679 TOOL_CODE_SEARCH.to_string(),
1680 "Bounded source search".to_string(),
1681 serde_json::json!({"type": "object"}),
1682 ),
1683 ])),
1684 false,
1685 );
1686
1687 append_runtime_tool_prompt_sections_for_profile(
1688 &mut powershell_prompt,
1689 &snapshot,
1690 false,
1691 ResolvedShellPromptProfile::PowerShell,
1692 );
1693 append_runtime_tool_prompt_sections_for_profile(
1694 &mut unix_prompt,
1695 &snapshot,
1696 false,
1697 ResolvedShellPromptProfile::UnixLike,
1698 );
1699
1700 assert!(powershell_prompt.contains("## Active Tools"));
1701 assert!(powershell_prompt.contains("`Get-ChildItem`"));
1702 assert!(powershell_prompt.contains("`Select-String`"));
1703 assert!(!powershell_prompt.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1704
1705 assert!(unix_prompt.contains("## Active Tools"));
1706 assert!(unix_prompt.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1707 assert!(unix_prompt.contains("Prefer `code_search` over `rg`/`grep` for code"));
1708 assert!(unix_prompt.contains("Advanced `code_search` takes `query`"));
1709 assert!(unix_prompt.contains("literal smart-case"));
1710 assert!(!unix_prompt.contains("`Get-ChildItem`"));
1711 }
1712
1713 #[test]
1714 fn runtime_tool_prompt_sections_include_catalog_metadata() {
1715 let mut prompt = "Base prompt".to_string();
1716 let snapshot = SessionToolCatalogSnapshot::new(
1717 7,
1718 9,
1719 true,
1720 false,
1721 Some(std::sync::Arc::new(vec![
1722 ToolDefinition::function(
1723 TOOL_EXEC_COMMAND.to_string(),
1724 "Search".to_string(),
1725 serde_json::json!({"type": "object"}),
1726 ),
1727 ToolDefinition::function(
1728 TOOL_APPLY_PATCH.to_string(),
1729 "File".to_string(),
1730 serde_json::json!({"type": "object"}),
1731 ),
1732 ])),
1733 false,
1734 );
1735
1736 append_runtime_tool_prompt_sections_for_profile(
1737 &mut prompt,
1738 &snapshot,
1739 true,
1740 ShellPromptProfile::Auto.resolve_for_current_platform(),
1741 );
1742
1743 assert!(prompt.contains("## Active Tools"));
1744 assert!(prompt.contains("[Runtime Tool Catalog]"));
1745 assert!(prompt.contains("catalog_tools: 2"));
1746 assert!(prompt.contains("currently_available_tools: exec_command, apply_patch"));
1747 assert!(prompt.contains("request_user_input_enabled: false"));
1748 }
1749
1750 #[test]
1751 fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
1752 let mut prompt = "Base prompt".to_string();
1753 let first = SessionToolCatalogSnapshot::new(
1754 1,
1755 2,
1756 false,
1757 false,
1758 Some(std::sync::Arc::new(vec![ToolDefinition::function(
1759 TOOL_EXEC_COMMAND.to_string(),
1760 "Search".to_string(),
1761 serde_json::json!({"type": "object"}),
1762 )])),
1763 false,
1764 );
1765 let second = SessionToolCatalogSnapshot::new(
1766 7,
1767 9,
1768 true,
1769 true,
1770 Some(std::sync::Arc::new(vec![ToolDefinition::function(
1771 TOOL_APPLY_PATCH.to_string(),
1772 "File".to_string(),
1773 serde_json::json!({"type": "object"}),
1774 )])),
1775 false,
1776 );
1777
1778 let shell = ShellPromptProfile::Auto.resolve_for_current_platform();
1779 append_runtime_tool_prompt_sections_for_profile(&mut prompt, &first, true, shell);
1780 append_runtime_tool_prompt_sections_for_profile(&mut prompt, &second, true, shell);
1781
1782 assert_eq!(prompt.matches("## Active Tools").count(), 1);
1783 assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
1784 assert!(prompt.contains("version: 7"));
1785 assert!(!prompt.contains("version: 1"));
1786 assert!(prompt.contains("request_user_input_enabled: true"));
1787 assert!(!prompt.contains("request_user_input_enabled: false"));
1788 }
1789}