Skip to main content

vtcode_core/prompts/
guidelines.rs

1use std::collections::BTreeSet;
2use std::fmt::Write as _;
3
4use crate::config::constants::tools;
5use crate::config::types::{CapabilityLevel, 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
26/// Shared cross-turn resume pointer (invariant #22). The hint body itself stays
27/// transient via `append_transient_turn_notes`; tool guidance only advertises
28/// that a turn-start `Exec session resume:` note carries the live ids so a
29/// resumed or compacted session needs zero identity reconstruction.
30const 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
33/// The single home of `start_planning` guidance, shared by the Minimal and
34/// Default Active Tools. The tool itself asks the user before entering
35/// planning (`require_confirmation`), except under full-auto or
36/// skip-confirmations, so the model calls it rather than proposing it in prose.
37const 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
39/// Planning-workflow `task_tracker` index rules. While planning, the tracker
40/// routes to the plan sidecar, which rejects index 0 (`index_path components
41/// must be >= 1`); checklist-level `index: 0` completion exists only outside
42/// planning, so neither line advertises it.
43const 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
46/// Context-token ceiling at or below which Active Tools documentation
47/// resolves to the Minimal density.
48const MINIMAL_CONTEXT_TOKEN_CEILING: usize = 32_000;
49
50/// The single home of the parallel-call hint, shared by the Minimal and
51/// Default Active Tools; emitted only when the provider keeps parallel tool
52/// configuration enabled.
53const PARALLEL_TOOLS_LINE: &str = "- Run independent tools in parallel when inputs do not depend on each other.";
54
55/// The single home of the `code_search` filter hygiene rule, shared by the
56/// Minimal, Default, and planning Active Tools variants.
57const CODE_SEARCH_FILTER_LINE: &str = "- `code_search`: omit unused filters; no empty values (`path: \"\"`).";
58
59/// The single home of the `request_user_input` threshold rule, shared by the
60/// Minimal planning addendum and the planning Active Tools.
61const REQUEST_USER_INPUT_LINE: &str =
62    "- Use `request_user_input` only for material blockers remaining after repository exploration.";
63
64/// Read-only capability line shared by the capability-level and
65/// tool-fallback arms of `capability_mode_line`.
66const CAPABILITY_READ_ONLY_LINE: &str =
67    "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.";
68
69/// Shared `write_stdin` recovery clause; each profile adds its own
70/// rerun/wait policy around it.
71const WRITE_STDIN_RECOVERY_CLAUSE: &str = "; if missing, recover prior output";
72
73/// Policy lines shared by both shell profiles: the profile governs syntax
74/// examples only, and VT Code never translates flags across shells.
75const 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/// Documentation density is independent of the tools a session may execute.
78#[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
107/// Which guidance-gating tools a session exposes, computed once per build so
108/// every Active Tools variant branches on the same membership policy.
109struct 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
140/// Render from resolved capabilities, without changing tool authorization.
141pub(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            // Safeguard, verification, wait-instead-of-poll, and spool/preview
193            // rules already ship in Runtime Guidance.
194            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
205/// Generate compact cross-tool guidance with an explicit shell prompt profile.
206/// `parallel_tools` mirrors the provider's parallel-tool configuration: when
207/// false, the parallel-call hint is not emitted instead of stripped post hoc.
208pub(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        // Verifier discipline: the anti-blind-editing gate only clears on a
250        // truthful exit 0. Runtime owns truncator elision and unverified
251        // classification (`tool_intent/activity.rs`, spool processing); the
252        // prompt keeps only the outcome rule so wording cannot drift from
253        // enforcement.
254        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        // Low-effort models sometimes report a change as done without
256        // exercising it: require a real check (tests, type-checker, build,
257        // or the changed command itself). A syntax-only check, or a check
258        // command that failed to start, does not count. Applies to Sonnet
259        // 5.5 at `low` effort and any route where verification is skipped.
260        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        // Tool-latency tail is dominated by full builds (observed p90 ~18s):
262        // verify incrementally first. Kept tool-agnostic: fast checks exist
263        // in every stack (`cargo check`, `tsc --noEmit`, `pytest --collect-only`).
264        lines.push("- Run fast checks before full builds.".to_string());
265    }
266    // Tool-failure diagnosis, waiting on returned `next_wait_args` instead of
267    // polling, the safeguard rule, the verification outcome rule (report
268    // completion only after a check you ran), and spool paging with
269    // per-result preview bounds each have one home in Runtime
270    // Guidance, which every profile includes; do not restate them here.
271    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
313/// Select documentation density using the active route and session budgets.
314pub 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    append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
325    let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
326    let profile = ToolGuidanceProfile::resolve(
327        crate::compaction::effective_context_budget(config, provider, model),
328        vtcode_commons::estimate_tokens(prompt),
329        config.map_or(0, |cfg| cfg.agent.max_system_prompt_tokens as usize),
330        pricing.and_then(|price| price.input),
331        config.and_then(|cfg| cfg.agent.harness.max_budget_usd),
332    );
333    let parallel_tools = provider.supports_parallel_tool_config(model);
334    // The planning mode section owns the output contract at every density.
335    // Default planning tool guidance contains no parallel-call hint.
336    if tool_snapshot.planning_active && profile == ToolGuidanceProfile::Default {
337        return;
338    }
339    remove_prompt_section(prompt, "## Active Tools");
340    let capability_level = Some(infer_capability_level(&names));
341    let mut guidance =
342        generate_tool_guidelines_with_capabilities(&names, capability_level, shell_profile, profile, parallel_tools);
343    if tool_snapshot.planning_active {
344        append_minimal_planning_addendum(&mut guidance, &names);
345    }
346    append_prompt_block(prompt, guidance.trim_start_matches('\n'));
347}
348
349/// Replace the runtime tool sections with guidance built from `names`, plus
350/// optional catalog metadata. Callers that continue into a density rebuild
351/// pass the same `names` through instead of re-deriving them.
352fn append_runtime_tool_sections_with_names(
353    prompt: &mut String,
354    tool_snapshot: &SessionToolCatalogSnapshot,
355    names: &[String],
356    include_catalog_metadata: bool,
357    shell_profile: ResolvedShellPromptProfile,
358) {
359    remove_prompt_section(prompt, "## Active Tools");
360    remove_prompt_section(prompt, "[Runtime Tool Catalog]");
361    while prompt.ends_with('\n') {
362        prompt.pop();
363    }
364
365    let guidelines = generate_runtime_tool_guidelines_for_profile(names, tool_snapshot.planning_active, shell_profile);
366    if !guidelines.is_empty() {
367        append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
368    }
369
370    if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
371        let active_tools = if tool_snapshot.active_tool_names.is_empty() {
372            "none".to_string()
373        } else {
374            tool_snapshot.active_tool_names.join(", ")
375        };
376        let catalog_metadata = format!(
377            "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
378            tool_snapshot.version,
379            tool_snapshot.epoch,
380            tool_snapshot.catalog_tools(),
381            tool_snapshot.available_tools(),
382            active_tools,
383            tool_snapshot.request_user_input_enabled,
384        );
385        append_prompt_block(prompt, &catalog_metadata);
386    }
387}
388
389/// Compact tool reminders; the planning mode section retains the canonical
390/// research, step-quality, output, and persistence contract at every density.
391fn append_minimal_planning_addendum(guidance: &mut String, names: &[String]) {
392    guidance.push_str("\n- Planning is read-only.");
393    let read_tools = [TOOL_READ_FILE, TOOL_GREP_FILE, TOOL_CODE_SEARCH, TOOL_LIST_FILES]
394        .into_iter()
395        .filter(|tool| names.iter().any(|name| name == tool))
396        .collect::<Vec<_>>();
397    if read_tools.is_empty() {
398        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`.");
399    } else {
400        guidance.push_str(&format!(
401            "\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`.",
402            read_tools.join("`/`")
403        ));
404    }
405    if names.iter().any(|name| name == TOOL_TASK_TRACKER) {
406        guidance.push('\n');
407        guidance.push_str(PLANNING_TASK_TRACKER_COMPACT_LINE);
408    }
409    if names.iter().any(|name| name == TOOL_REQUEST_USER_INPUT) {
410        guidance.push('\n');
411        guidance.push_str(REQUEST_USER_INPUT_LINE);
412    }
413}
414
415/// Append a compact summary of tools omitted from a client-local wire payload.
416///
417/// The listing is bounded like the `## Skills` routing section: at most
418/// `DEFERRED_TOOLS_MAX_GROUPS` groups are named and the rest collapse into one
419/// overflow line, so MCP-heavy sessions cannot bloat every request. Group
420/// descriptions are server-provided and unbounded, so each line is truncated
421/// to `DEFERRED_TOOLS_MAX_DESC_CHARS` characters.
422pub fn append_deferred_tools_prompt_section(prompt: &mut String, tools: &[ToolDefinition]) {
423    remove_prompt_section(prompt, "[Deferred Tools]");
424
425    let mut groups: Vec<_> = tool_groups(tools)
426        .into_iter()
427        .filter(|group| group.deferred_count > 0)
428        .collect();
429    let overflow = groups.len().saturating_sub(DEFERRED_TOOLS_MAX_GROUPS);
430    groups.truncate(DEFERRED_TOOLS_MAX_GROUPS);
431
432    let mut lines: Vec<String> = groups
433        .into_iter()
434        .map(|group| {
435            let description = truncate_deferred_description(group.description.as_deref().unwrap_or_default());
436            format!("- {} ({} tools): {}", group.name, group.deferred_count, description)
437        })
438        .collect();
439    if overflow > 0 {
440        lines.push(format!("(+{overflow} more deferred groups available — use `search_tools` to find them)"));
441    }
442
443    let unnamespaced_deferred = tools
444        .iter()
445        .filter(|tool| tool.namespace.is_none() && tool.defer_loading == Some(true))
446        .count();
447    if unnamespaced_deferred > 0 {
448        lines.push(format!("- {unnamespaced_deferred} additional deferred tools"));
449    }
450
451    if lines.is_empty() {
452        return;
453    }
454
455    let section = format!(
456        "[Deferred Tools]\n{}\nUse `search_tools` to find a deferred capability. Selected definitions become available in the next request segment.",
457        lines.join("\n")
458    );
459    append_prompt_block(prompt, &section);
460}
461
462/// Maximum deferred-tool groups named in the `[Deferred Tools]` prompt
463/// section before the rest collapse into one overflow line.
464const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
465/// Maximum characters of a group description in the section. Mirrors the
466/// `## Skills` line truncation: server-provided text must not bloat the prompt.
467const DEFERRED_TOOLS_MAX_DESC_CHARS: usize = 120;
468
469fn truncate_deferred_description(description: &str) -> String {
470    if description.chars().count() <= DEFERRED_TOOLS_MAX_DESC_CHARS {
471        return description.to_string();
472    }
473    let truncated: String = description.chars().take(DEFERRED_TOOLS_MAX_DESC_CHARS).collect();
474    format!("{}...", truncated.trim_end())
475}
476
477fn append_prompt_block(prompt: &mut String, block: &str) {
478    if block.is_empty() {
479        return;
480    }
481
482    if prompt.is_empty() {
483        prompt.push_str(block);
484    } else {
485        let _ = write!(prompt, "\n\n{block}");
486    }
487}
488
489fn remove_prompt_section(prompt: &mut String, section_header: &str) {
490    while let Some((section_start, section_end)) = find_prompt_section_bounds(prompt, section_header) {
491        prompt.replace_range(section_start..section_end, "");
492    }
493}
494
495fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
496    crate::prompts::sections::find_prompt_section_bounds(prompt, section_header, SectionBoundaryMode::BracketOrMarkdown)
497}
498
499fn generate_runtime_tool_guidelines_for_profile(
500    available_tools: &[String],
501    planning_active: bool,
502    shell_profile: ResolvedShellPromptProfile,
503) -> String {
504    if !planning_active {
505        // Measurement parity: this Default-density build feeds the prompt size
506        // that `ToolGuidanceProfile::resolve` reads, so it keeps shipping the
507        // parallel-call hint unconditionally like the static composition path.
508        return generate_tool_guidelines_for_profile(available_tools, None, shell_profile, true);
509    }
510
511    let presence = ToolPresence::of(available_tools);
512    let has_exec = presence.exec;
513    let has_search = presence.code_search;
514    let has_read_file = presence.read_file;
515    let has_list_files = presence.list_files;
516    let has_request_user_input = presence.request_user_input;
517    let has_task_tracker = presence.task_tracker;
518
519    let mut lines = vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
520    lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
521    if let Some(browse_guidance) =
522        browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
523    {
524        lines.push(browse_guidance);
525    }
526    if has_exec {
527        lines.push("- In Planning workflow, use `exec_command` only for read-only verification.".to_string());
528    }
529    if has_search {
530        lines.push(CODE_SEARCH_FILTER_LINE.to_string());
531    }
532    if has_task_tracker {
533        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());
534        lines.push("- Keep blockers and verification open in `task_tracker` until resolved.".to_string());
535        lines.push(PLANNING_TASK_TRACKER_INDEX_LINE.to_string());
536    }
537    if has_request_user_input {
538        lines.push(REQUEST_USER_INPUT_LINE.to_string());
539    }
540    if has_search || has_exec {
541        lines.push("- If calls repeat without progress, tighten the plan instead of retrying identically.".to_string());
542    }
543    if available_tools.iter().any(|name| name == "record_decision") {
544        lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
545    }
546
547    format!("\n\n## Active Tools\n{}", lines.join("\n"))
548}
549
550fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
551    tool_snapshot
552        .active_tool_names
553        .iter()
554        .cloned()
555        .collect::<BTreeSet<_>>()
556        .into_iter()
557        .collect()
558}
559
560fn browse_tool_guidance(
561    has_exec: bool,
562    has_search: bool,
563    has_list_files: bool,
564    has_read_file: bool,
565    shell_profile: ResolvedShellPromptProfile,
566) -> Option<String> {
567    if has_exec {
568        return Some(shell_browse_guidance(shell_profile, has_search));
569    }
570
571    if !(has_search || has_list_files || has_read_file) {
572        return None;
573    }
574
575    Some("- Use available read-only repository tools for browsing; do not modify files.".to_string())
576}
577
578pub(crate) fn render_shell_profile_guidance(shell_profile: ResolvedShellPromptProfile) -> String {
579    let profile_lines = match shell_profile {
580        ResolvedShellPromptProfile::UnixLike => {
581            "- 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."
582        }
583        ResolvedShellPromptProfile::PowerShell => {
584            "- 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."
585        }
586    };
587    format!("## Shell Profile\n{profile_lines}\n{SHELL_PROFILE_POLICY_SUFFIX}")
588}
589
590fn shell_browse_guidance(shell_profile: ResolvedShellPromptProfile, has_search: bool) -> String {
591    // Sessions show `exec_command` + `rg` crowding out `code_search`
592    // (observed 654 exec vs 15 code_search in one run): `rg` scans text
593    // while `code_search` resolves definitions and exact usages, so route
594    // code search to the dedicated tool whenever it is available.
595    const SEARCH_PREFERENCE_UNIX: &str = " Prefer `code_search` over `rg`/`grep` for code.";
596    const SEARCH_PREFERENCE_POWERSHELL: &str = " Prefer `code_search` over `Select-String` for code.";
597    match shell_profile {
598        ResolvedShellPromptProfile::UnixLike => {
599            let mut line = if has_search {
600                "- Use `exec_command.cmd` with `ls`, `find`, `cat`, `sed`, and `awk` for repository browsing."
601                    .to_string()
602            } else {
603                "- Use `exec_command.cmd` with `ls`, `rg`, `find`, `cat`, `sed`, and `awk` for repository browsing."
604                    .to_string()
605            };
606            if has_search {
607                line.push_str(SEARCH_PREFERENCE_UNIX);
608            }
609            line
610        }
611        ResolvedShellPromptProfile::PowerShell => {
612            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();
613            if has_search {
614                line.push_str(SEARCH_PREFERENCE_POWERSHELL);
615            }
616            line
617        }
618    }
619}
620
621fn shell_task_guidance(shell_profile: ResolvedShellPromptProfile) -> &'static str {
622    match shell_profile {
623        ResolvedShellPromptProfile::UnixLike => {
624            "- 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`)."
625        }
626        ResolvedShellPromptProfile::PowerShell => {
627            "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks using native PowerShell syntax."
628        }
629    }
630}
631
632fn background_exec_guidance() -> &'static str {
633    "- 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."
634}
635
636fn read_only_batching_guidance(has_read_file: bool) -> &'static str {
637    if has_read_file {
638        "- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
639    } else {
640        "- Batch independent read-only calls; order dependent reads, and serialize mutations."
641    }
642}
643
644fn code_search_guidance(has_exec: bool, _shell_profile: ResolvedShellPromptProfile) -> String {
645    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/`.";
646    if has_exec {
647        format!("{BASE} Use `exec_command` or a skill for syntax patterns.")
648    } else {
649        BASE.to_string()
650    }
651}
652
653fn capability_mode_line(
654    capability_level: Option<CapabilityLevel>,
655    has_exec: bool,
656    has_file: bool,
657) -> Option<&'static str> {
658    match capability_level {
659        Some(CapabilityLevel::Basic) => {
660            Some("- Capabilities: limited. Ask the user to enable more capabilities if file work is required.")
661        }
662        Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(CAPABILITY_READ_ONLY_LINE),
663        _ if !has_exec && !has_file => Some(CAPABILITY_READ_ONLY_LINE),
664        _ => None,
665    }
666}
667
668/// Infer capability level from available tools.
669pub(crate) fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
670    let presence = ToolPresence::of(available_tools);
671    if presence.code_search {
672        CapabilityLevel::CodeSearch
673    } else if presence.apply_patch {
674        CapabilityLevel::Editing
675    } else if presence.exec {
676        CapabilityLevel::Bash
677    } else if presence.list_files {
678        CapabilityLevel::FileListing
679    } else if presence.read_file {
680        CapabilityLevel::FileReading
681    } else {
682        CapabilityLevel::Basic
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::config::types::ShellPromptProfile;
690
691    /// Default-density guidance with the platform shell profile and the
692    /// parallel-call hint, matching what static composition ships.
693    fn guidelines_for(tools: &[String], capability_level: Option<CapabilityLevel>) -> String {
694        generate_tool_guidelines_for_profile(
695            tools,
696            capability_level,
697            ShellPromptProfile::Auto.resolve_for_current_platform(),
698            true,
699        )
700    }
701
702    #[test]
703    fn matrix_guidance_has_presence_and_budget_in_both_densities() {
704        for profile in [ToolGuidanceProfile::Default, ToolGuidanceProfile::Minimal] {
705            let enabled = generate_tool_guidelines_with_capabilities(
706                &["matrix".to_string(), "request_user_input".to_string()],
707                None,
708                ResolvedShellPromptProfile::UnixLike,
709                profile,
710                false,
711            );
712            let disabled = generate_tool_guidelines_with_capabilities(
713                &["request_user_input".to_string()],
714                None,
715                ResolvedShellPromptProfile::UnixLike,
716                profile,
717                false,
718            );
719            assert_eq!(enabled.matches(MATRIX_GUIDANCE).count(), 1);
720            assert!(enabled.contains("declared input changes"));
721            assert!(enabled.contains("effective permissions and sandbox gates"));
722            assert!(enabled.contains("Replay restores admission before discovery"));
723            assert!(enabled.contains("user cancel is terminal"));
724            assert!(!disabled.contains("scheduler-owned"));
725            assert!(vtcode_commons::estimate_tokens(&enabled) <= 220, "{profile:?}: {enabled}");
726        }
727    }
728
729    /// Universal rules have one home in Runtime Guidance (or the shared
730    /// contract). Compose every static profile with each tool-guidance variant
731    /// and the Harness Limits section, and check each rule marker lands once.
732    #[test]
733    fn universal_rules_have_one_home_across_composed_prompt_sections() {
734        use crate::config::types::SystemPromptMode;
735        use crate::prompts::harness_limits::upsert_harness_limits_section;
736        use crate::prompts::static_prompts::static_profile_prompt;
737        use crate::prompts::system::PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE;
738
739        let shell = ResolvedShellPromptProfile::UnixLike;
740        let execution_tools = [
741            TOOL_EXEC_COMMAND,
742            TOOL_WRITE_STDIN,
743            TOOL_APPLY_PATCH,
744            TOOL_CODE_SEARCH,
745            TOOL_TASK_TRACKER,
746            TOOL_START_PLANNING,
747            TOOL_REQUEST_USER_INPUT,
748        ]
749        .map(str::to_owned)
750        .to_vec();
751        let planning_tools = [
752            TOOL_EXEC_COMMAND,
753            TOOL_WRITE_STDIN,
754            TOOL_CODE_SEARCH,
755            TOOL_GREP_FILE,
756            TOOL_TASK_TRACKER,
757            TOOL_REQUEST_USER_INPUT,
758        ]
759        .map(str::to_owned)
760        .to_vec();
761        let mut minimal_planning = generate_tool_guidelines_with_capabilities(
762            &planning_tools,
763            None,
764            shell,
765            ToolGuidanceProfile::Minimal,
766            false,
767        );
768        append_minimal_planning_addendum(&mut minimal_planning, &planning_tools);
769        let variants = [
770            ("default", generate_tool_guidelines_for_profile(&execution_tools, None, shell, true), false),
771            (
772                "minimal",
773                generate_tool_guidelines_with_capabilities(
774                    &execution_tools,
775                    None,
776                    shell,
777                    ToolGuidanceProfile::Minimal,
778                    true,
779                ),
780                false,
781            ),
782            ("default planning", generate_runtime_tool_guidelines_for_profile(&planning_tools, true, shell), true),
783            ("minimal planning", minimal_planning, true),
784        ];
785        // Each marker names one universal rule that lives in Runtime Guidance
786        // or the shared contract and must not be restated by tool sections.
787        let markers = [
788            "never claim a check passed",
789            "Diagnose failures; change approach",
790            "Use returned `next_wait_args`",
791            "Treat empty searches as evidence",
792            "Check optional tools once",
793            "small non-overlapping ranges",
794            "accumulated output never exhausts tool access",
795            "additional_permissions",
796            "bypass safeguards",
797            "Delegate only sizeable",
798            "Across compaction",
799        ];
800
801        for mode in [
802            SystemPromptMode::Default,
803            SystemPromptMode::Minimal,
804            SystemPromptMode::Lightweight,
805            SystemPromptMode::Specialized,
806        ] {
807            for (variant, guidance, planning) in &variants {
808                let mut prompt = static_profile_prompt(mode).to_owned();
809                if *planning {
810                    prompt.push_str("\n\n");
811                    prompt.push_str(PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE);
812                }
813                prompt.push_str(guidance);
814                upsert_harness_limits_section(&mut prompt, 32, 600, 2);
815                for marker in markers {
816                    assert_eq!(
817                        prompt.matches(marker).count(),
818                        1,
819                        "{mode:?} with {variant} tool guidance should state {marker:?} exactly once"
820                    );
821                }
822                let start_planning_mentions = prompt.matches("start_planning").count();
823                assert_eq!(start_planning_mentions, usize::from(!*planning), "{mode:?} with {variant} tool guidance");
824            }
825        }
826    }
827
828    #[test]
829    fn documentation_profile_respects_context_tokens_and_known_cost() {
830        assert_eq!(ToolGuidanceProfile::resolve(32_000, 100, 1000, None, None), ToolGuidanceProfile::Minimal);
831        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 100, 1000, None, Some(0.0)), ToolGuidanceProfile::Default);
832        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 1001, 1000, None, None), ToolGuidanceProfile::Minimal);
833        assert_eq!(
834            ToolGuidanceProfile::resolve(1_000_000, 1000, 2000, Some(0.00001), Some(0.001)),
835            ToolGuidanceProfile::Minimal
836        );
837    }
838
839    #[test]
840    fn minimal_and_default_tool_guidance_snapshots() {
841        let tools = vec![TOOL_READ_FILE.to_owned()];
842        let minimal = generate_tool_guidelines_with_capabilities(
843            &tools,
844            None,
845            ResolvedShellPromptProfile::UnixLike,
846            ToolGuidanceProfile::Minimal,
847            false,
848        );
849        assert_eq!(
850            minimal,
851            "\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."
852        );
853        let default = generate_tool_guidelines_with_capabilities(
854            &tools,
855            None,
856            ResolvedShellPromptProfile::UnixLike,
857            ToolGuidanceProfile::Default,
858            false,
859        );
860        assert_eq!(
861            default,
862            "\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`."
863        );
864    }
865
866    #[test]
867    fn public_decision_guidance_is_optional_and_bounded() {
868        for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
869            let enabled = generate_tool_guidelines_with_capabilities(
870                &[TOOL_READ_FILE.into(), "record_decision".into()],
871                None,
872                ResolvedShellPromptProfile::UnixLike,
873                profile,
874                false,
875            );
876            let disabled = generate_tool_guidelines_with_capabilities(
877                &[TOOL_READ_FILE.into()],
878                None,
879                ResolvedShellPromptProfile::UnixLike,
880                profile,
881                false,
882            );
883            assert!(enabled.contains(PUBLIC_DECISION_GUIDANCE));
884            assert!(!disabled.contains("record_decision"));
885            assert!(enabled.len().saturating_sub(disabled.len()) < 300);
886            assert!(!enabled.contains("chain of thought"));
887        }
888    }
889
890    #[test]
891    fn tool_guidance_uses_actual_parallel_capabilities_for_three_families() {
892        use crate::config::constants::models;
893        use crate::llm::provider::LLMProvider;
894        use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
895        let providers: [(Box<dyn LLMProvider>, &str); 3] = [
896            (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
897            (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
898            (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
899        ];
900        for (provider, model) in providers {
901            for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
902                let parallel = provider.supports_parallel_tool_config(model);
903                let text = generate_tool_guidelines_with_capabilities(
904                    &[TOOL_EXEC_COMMAND.to_owned()],
905                    None,
906                    ResolvedShellPromptProfile::UnixLike,
907                    profile,
908                    parallel,
909                );
910                assert_eq!(text.contains("tools in parallel"), parallel);
911                let serial = generate_tool_guidelines_with_capabilities(
912                    &[TOOL_EXEC_COMMAND.to_owned()],
913                    None,
914                    ResolvedShellPromptProfile::UnixLike,
915                    profile,
916                    false,
917                );
918                assert!(!serial.contains("tools in parallel"));
919            }
920        }
921    }
922
923    #[test]
924    fn test_read_only_capability_detection() {
925        let tools = vec![TOOL_CODE_SEARCH.to_string()];
926        let guidelines = guidelines_for(&tools, None);
927        assert!(guidelines.contains("Capabilities: read-only"));
928        assert!(guidelines.contains("do not modify files"));
929    }
930
931    #[test]
932    fn test_tool_preference_guidance() {
933        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_CODE_SEARCH.to_string()];
934        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
935        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
936        assert!(guidelines.contains("literal smart-case"));
937        assert!(guidelines.contains("exact syntactic usages"));
938        assert!(guidelines.contains("\"result_types\":[\"definition\"]"));
939        assert!(guidelines.contains("Do not JSON-encode arrays or integers as strings"));
940        assert!(guidelines.contains("omit unused filters"));
941        assert!(guidelines.contains("path: \"\""));
942        assert!(guidelines.contains("git diff -- <path>"));
943        assert!(guidelines.contains("build tools"));
944        assert!(guidelines.contains("test tools"));
945        // Search steering: with both tools present the browse line must
946        // prefer `code_search` over `rg` for code (session evidence showed
947        // `rg`-via-exec crowding out `code_search` 654:15).
948        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
949        // Latency steering: fast checks before full builds (tool-agnostic).
950        assert!(guidelines.contains("Run fast checks before full builds"));
951        // Completion-as-checkpoint guidance lives in the operating profiles;
952        // the guidelines section no longer repeats it.
953        assert!(!guidelines.contains("Completion is a checkpoint"));
954    }
955
956    #[test]
957    fn test_edit_workflow_guidance() {
958        let tools = vec![TOOL_APPLY_PATCH.to_string()];
959        let guidelines = guidelines_for(&tools, None);
960        assert!(guidelines.contains("Use `apply_patch`"));
961        assert!(guidelines.contains("patches small"));
962        // Completion-as-checkpoint guidance lives in the operating profiles;
963        // the guidelines section no longer repeats it.
964        assert!(!guidelines.contains("verification resolved"));
965    }
966
967    #[test]
968    fn test_vt_code_guidance_omits_task_tracker() {
969        let tools = vec![
970            TOOL_EXEC_COMMAND.to_string(),
971            TOOL_WRITE_STDIN.to_string(),
972            TOOL_APPLY_PATCH.to_string(),
973        ];
974        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
975
976        assert!(guidelines.contains("exec_command.cmd"));
977        for command in ["ls", "rg", "find", "cat", "sed", "awk"] {
978            assert!(
979                guidelines.contains(&format!("`{command}`")),
980                "{command} should be shown as an exec_command.cmd example"
981            );
982        }
983        assert!(guidelines.contains("`write_stdin`"));
984        assert!(guidelines.contains("At most three live background processes"));
985        assert!(guidelines.contains("`apply_patch`"));
986        assert!(!guidelines.contains("task_tracker"));
987        assert!(!guidelines.contains("list_files"));
988        assert!(!guidelines.contains("read_file"));
989    }
990
991    #[test]
992    fn task_tracker_guidance_explains_action_aware_indices() {
993        let guidelines = generate_runtime_tool_guidelines_for_profile(
994            &[TOOL_TASK_TRACKER.to_string()],
995            true,
996            ResolvedShellPromptProfile::UnixLike,
997        );
998
999        assert!(guidelines.contains(&format!("\n{PLANNING_TASK_TRACKER_INDEX_LINE}")));
1000        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("positive flat indices"));
1001        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("(index 0 is invalid while planning)"));
1002        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("full checklist replacement"));
1003        // The planning sidecar rejects index 0, so no planning line may present
1004        // it as a valid checklist-completion index.
1005        for line in [PLANNING_TASK_TRACKER_INDEX_LINE, PLANNING_TASK_TRACKER_COMPACT_LINE] {
1006            assert!(line.contains("index 0 is invalid while planning"), "{line}");
1007            assert!(!line.contains("reserved"), "{line}");
1008            assert!(!line.contains("index: 0"), "{line}");
1009        }
1010    }
1011
1012    #[test]
1013    fn unix_like_guidance_makes_command_reuse_explicit() {
1014        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_WRITE_STDIN.to_string()];
1015        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1016
1017        assert!(guidelines.contains("one-shot `exec_command` calls"));
1018        assert!(guidelines.contains("`!!`, `!$`, `!ssh`, or `fc`"));
1019        assert!(guidelines.contains("write full command arguments explicitly"));
1020        assert!(guidelines.contains("conversation or tool results"));
1021        assert!(guidelines.contains("existing `session_id`"));
1022        assert!(guidelines.contains("background: true"));
1023        assert!(guidelines.contains("Bash `histverify`"));
1024        assert!(guidelines.contains("zsh `HIST_VERIFY`"));
1025        // Cross-turn resume (invariant #22): the live id arrives via a
1026        // turn-start `Exec session resume:` hint when a prior turn ended mid-run.
1027        assert!(guidelines.contains("`Exec session resume:`"));
1028        assert!(guidelines.contains("prior turn ended mid-run"));
1029        // No `code_search` in this profile: the search-preference clause
1030        // must not spend budget naming an unavailable tool.
1031        assert!(!guidelines.contains("Prefer `code_search` over `rg`"));
1032    }
1033
1034    #[test]
1035    fn write_stdin_guidance_advertises_cross_turn_resume_hint() {
1036        let tools = vec![TOOL_WRITE_STDIN.to_string()];
1037        let default_guidance =
1038            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1039        assert!(default_guidance.contains("`Exec session resume:`"));
1040        assert!(default_guidance.contains("prior turn ended mid-run"));
1041        assert!(default_guidance.contains("if missing, recover prior output"));
1042        assert!(default_guidance.contains("rerun only for fresh results"));
1043
1044        let minimal = generate_tool_guidelines_with_capabilities(
1045            &tools,
1046            None,
1047            ResolvedShellPromptProfile::UnixLike,
1048            ToolGuidanceProfile::Minimal,
1049            false,
1050        );
1051        assert!(minimal.contains("`Exec session resume:`"));
1052        assert!(minimal.contains("prior turn ended mid-run"));
1053        assert!(minimal.contains("if missing, recover prior output"));
1054    }
1055
1056    #[test]
1057    fn powershell_guidance_uses_native_command_examples() {
1058        let tools = vec![
1059            TOOL_EXEC_COMMAND.to_string(),
1060            TOOL_CODE_SEARCH.to_string(),
1061            TOOL_APPLY_PATCH.to_string(),
1062        ];
1063        let guidelines =
1064            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::PowerShell, true);
1065
1066        assert!(guidelines.contains("native PowerShell commands"));
1067        assert!(guidelines.contains("`Get-ChildItem`"));
1068        assert!(guidelines.contains("`Select-String`"));
1069        assert!(guidelines.contains("native PowerShell syntax"));
1070        assert!(guidelines.contains("Prefer `code_search` over `Select-String` for code"));
1071        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1072        assert!(guidelines.contains("literal smart-case"));
1073        assert!(guidelines.contains("omit unused filters"));
1074        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1075        assert!(!guidelines.contains("shell history expansion"));
1076        assert!(!guidelines.contains("histverify"));
1077        assert!(!guidelines.contains("HIST_VERIFY"));
1078    }
1079
1080    #[test]
1081    fn shell_profile_prompt_keeps_policy_and_syntax_separate() {
1082        let unix = render_shell_profile_guidance(ResolvedShellPromptProfile::UnixLike);
1083        assert!(unix.contains("Active shell profile: `unix_like`"));
1084        assert!(unix.contains("does not rewrite GNU flags for macOS BSD tools"));
1085        assert!(unix.contains("controls prompt examples and expected command syntax only"));
1086        assert!(unix.contains("does not translate GNU-to-BSD"));
1087
1088        let powershell = render_shell_profile_guidance(ResolvedShellPromptProfile::PowerShell);
1089        assert!(powershell.contains("Active shell profile: `powershell`"));
1090        assert!(powershell.contains("WSL"));
1091        assert!(powershell.contains("Unix-like workflows"));
1092        assert!(powershell.contains("PowerShell-to-Unix"));
1093    }
1094
1095    #[test]
1096    fn test_harness_browse_tool_guidance() {
1097        let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
1098        let guidelines = guidelines_for(&tools, None);
1099        assert!(guidelines.contains("available read-only repository tools"));
1100        assert!(guidelines.contains("bounded `read_file` ranges"));
1101        assert!(!guidelines.contains("list_files"));
1102        assert!(!guidelines.contains("offset"));
1103        assert!(!guidelines.contains("per_page"));
1104    }
1105
1106    #[test]
1107    fn test_canonical_browse_tool_guidance_prefers_public_tools() {
1108        let tools = vec![
1109            TOOL_CODE_SEARCH.to_string(),
1110            TOOL_LIST_FILES.to_string(),
1111            "read_file".to_string(),
1112        ];
1113        let guidelines = guidelines_for(&tools, None);
1114        assert!(guidelines.contains("available read-only repository tools"));
1115        assert!(guidelines.contains("code_search"));
1116        assert!(guidelines.contains("bounded `read_file` ranges"));
1117    }
1118
1119    #[test]
1120    fn test_capability_basic_guidance() {
1121        let tools = vec![];
1122        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::Basic));
1123        assert!(guidelines.contains("Capabilities: limited"));
1124        assert!(guidelines.contains("enable more capabilities"));
1125    }
1126
1127    #[test]
1128    fn test_capability_file_reading_guidance() {
1129        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1130        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::FileReading));
1131        assert!(guidelines.contains("Capabilities: read-only"));
1132        assert!(guidelines.contains("do not modify"));
1133    }
1134
1135    #[test]
1136    fn test_full_capabilities_no_special_guidance() {
1137        let tools = vec![
1138            TOOL_APPLY_PATCH.to_string(),
1139            TOOL_EXEC_COMMAND.to_string(),
1140            TOOL_CODE_SEARCH.to_string(),
1141        ];
1142        let guidelines = generate_tool_guidelines_for_profile(
1143            &tools,
1144            Some(CapabilityLevel::Editing),
1145            ResolvedShellPromptProfile::UnixLike,
1146            true,
1147        );
1148
1149        assert!(!guidelines.contains("Capabilities: limited"));
1150        assert!(!guidelines.contains("Capabilities: read-only"));
1151    }
1152
1153    #[test]
1154    fn test_empty_tools_shows_read_only_capabilities() {
1155        let tools = vec![];
1156        let guidelines = guidelines_for(&tools, None);
1157        assert!(guidelines.contains("Capabilities: read-only"));
1158    }
1159
1160    #[test]
1161    fn test_planning_workflow_guidance_keeps_verification_open() {
1162        let tools = vec![
1163            TOOL_EXEC_COMMAND.to_string(),
1164            TOOL_TASK_TRACKER.to_string(),
1165            TOOL_CODE_SEARCH.to_string(),
1166        ];
1167        let guidelines =
1168            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1169        assert!(guidelines.contains("Keep `task_tracker` updated"));
1170        assert!(guidelines.contains("blockers and verification open"));
1171    }
1172
1173    #[test]
1174    fn test_capability_inference_precedence() {
1175        let tools = vec![TOOL_APPLY_PATCH.to_string(), TOOL_CODE_SEARCH.to_string()];
1176        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1177
1178        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_APPLY_PATCH.to_string()];
1179        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1180    }
1181
1182    #[test]
1183    fn test_capability_inference_variants() {
1184        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1185        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1186
1187        let tools = vec![TOOL_EXEC_COMMAND.to_string()];
1188        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
1189
1190        let tools = vec![TOOL_CODE_SEARCH.to_string()];
1191        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1192
1193        let tools = vec![TOOL_LIST_FILES.to_string()];
1194        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
1195
1196        let tools = vec!["read_file".to_string()];
1197        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
1198
1199        let tools = vec!["unknown_tool".to_string()];
1200        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
1201    }
1202
1203    #[test]
1204    fn test_guidelines_stay_compact() {
1205        let tools = vec![
1206            TOOL_EXEC_COMMAND.to_string(),
1207            TOOL_CODE_SEARCH.to_string(),
1208            "read_file".to_string(),
1209            TOOL_LIST_FILES.to_string(),
1210            "apply_patch".to_string(),
1211        ];
1212        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1213        assert!(guidelines.contains("Batch independent read-only calls"));
1214        assert!(guidelines.contains("code_search"));
1215        // Exec-capable profiles describe the fail-closed verifier pipeline
1216        // contract, including the terminal outcome and rejected shell forms.
1217        assert!(guidelines.contains("static read-only filtering pipelines use fail-closed `pipefail`"));
1218        assert!(guidelines.contains("Only terminal exit 0 clears verification"));
1219        assert!(guidelines.contains("dynamic syntax, mutating tails, `;`, and `||` do not qualify"));
1220        assert!(!guidelines.contains("elided"));
1221        assert!(guidelines.contains("max_output_tokens"));
1222        assert!(guidelines.contains("Build and Auto share tools and safety gates"));
1223        let approx_tokens = vtcode_commons::estimate_tokens(&guidelines);
1224        // The batching, bounded-diff, and verifier-discipline guardrails are
1225        // intentionally part of the compact shared prompt. Raised from 500 so
1226        // the verifier rule can state its reason (a visible exit status).
1227        // Raised to 580 for the Sonnet 5.5 low-effort real-check rule
1228        // (syntax-only/failed-to-start do not count; project package manager,
1229        // never sudo; state which check was skipped and why).
1230        assert!(approx_tokens < 580, "got ~{approx_tokens} tokens");
1231    }
1232
1233    #[test]
1234    fn deferred_tools_section_caps_groups_and_truncates_descriptions() {
1235        use crate::llm::provider::ToolNamespace;
1236
1237        fn deferred_mcp_tool(server: &str, tool: &str, description: &str) -> ToolDefinition {
1238            let mut definition = ToolDefinition::function(
1239                format!("mcp__{server}__{tool}"),
1240                "deferred".to_string(),
1241                serde_json::json!({"type": "object"}),
1242            );
1243            definition.namespace = Some(ToolNamespace {
1244                name: server.to_string(),
1245                description: description.to_string(),
1246            });
1247            definition.defer_loading = Some(true);
1248            definition
1249        }
1250
1251        let mut tools = Vec::new();
1252        for index in 0..10 {
1253            tools.push(deferred_mcp_tool(&format!("server-{index:02}"), "search", "Tools provided by MCP server"));
1254        }
1255        // One group with an unbounded server-provided description.
1256        tools.push(deferred_mcp_tool("server-long", "search", &"d".repeat(500)));
1257
1258        let mut prompt = "Base prompt".to_string();
1259        append_deferred_tools_prompt_section(&mut prompt, &tools);
1260
1261        assert!(prompt.contains("[Deferred Tools]"));
1262        assert!(prompt.contains("server-00 (1 tools)"));
1263        assert!(prompt.contains("server-07 (1 tools)"));
1264        assert!(!prompt.contains("server-08 (1 tools)"), "groups past the cap must collapse into overflow");
1265        assert!(!prompt.contains("server-09 (1 tools)"));
1266        assert!(
1267            prompt.contains("(+3 more deferred groups available"),
1268            "10 named groups + 1 long group over the 8-group cap overflows by 3"
1269        );
1270        assert!(!prompt.contains(&"d".repeat(121)), "group descriptions must stay truncated");
1271        assert!(prompt.contains("Use `search_tools` to find a deferred capability"));
1272
1273        // Idempotent: re-appending replaces the section instead of duplicating it.
1274        append_deferred_tools_prompt_section(&mut prompt, &tools);
1275        assert_eq!(prompt.matches("[Deferred Tools]").count(), 1);
1276    }
1277
1278    #[test]
1279    fn deferred_tools_section_omits_empty_and_fully_loaded_groups() {
1280        let mut prompt = "Base prompt".to_string();
1281        append_deferred_tools_prompt_section(&mut prompt, &[]);
1282        assert!(!prompt.contains("[Deferred Tools]"));
1283
1284        let loaded = ToolDefinition::function(
1285            "exec_command".to_string(),
1286            "Shell".to_string(),
1287            serde_json::json!({"type": "object"}),
1288        );
1289        let mut prompt = "Base prompt".to_string();
1290        append_deferred_tools_prompt_section(&mut prompt, &[loaded]);
1291        assert!(!prompt.contains("[Deferred Tools]"));
1292    }
1293
1294    #[test]
1295    fn test_parallel_tool_call_guidance() {
1296        let tools = vec![
1297            TOOL_EXEC_COMMAND.to_string(),
1298            TOOL_CODE_SEARCH.to_string(),
1299            TOOL_APPLY_PATCH.to_string(),
1300        ];
1301        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1302        assert!(guidelines.contains("parallel"), "Should include parallel tool call guidance");
1303        assert!(guidelines.contains("inputs do not depend"), "Should mention independent inputs");
1304    }
1305
1306    #[test]
1307    fn test_read_only_batching_guidance_is_explicit() {
1308        let tools = vec![
1309            TOOL_CODE_SEARCH.to_string(),
1310            TOOL_READ_FILE.to_string(),
1311            TOOL_LIST_FILES.to_string(),
1312        ];
1313        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1314
1315        assert!(guidelines.contains("Batch independent read-only calls"));
1316        assert!(guidelines.contains("`read_file` ranges"));
1317        assert!(guidelines.contains("serialize mutations"));
1318        // No exec tools in this profile: the verifier truthful-status rule is
1319        // exec-conditional and must not spend budget here.
1320        assert!(!guidelines.contains("Only terminal exit 0 clears verification"));
1321    }
1322
1323    #[test]
1324    fn execution_agents_can_suggest_planning_for_demanding_tasks() {
1325        let tools = vec![TOOL_START_PLANNING.to_string(), TOOL_EXEC_COMMAND.to_string()];
1326        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1327
1328        assert_eq!(guidelines.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1329        let minimal = generate_tool_guidelines_with_capabilities(
1330            &tools,
1331            None,
1332            ResolvedShellPromptProfile::UnixLike,
1333            ToolGuidanceProfile::Minimal,
1334            false,
1335        );
1336        assert_eq!(minimal.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1337        // Without the tool, no profile mentions it.
1338        let without = generate_tool_guidelines_for_profile(
1339            &[TOOL_EXEC_COMMAND.to_string()],
1340            None,
1341            ResolvedShellPromptProfile::UnixLike,
1342            true,
1343        );
1344        assert!(!without.contains("start_planning"));
1345    }
1346
1347    #[test]
1348    fn markdown_validation_is_optional_in_planning_and_execution() {
1349        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_READ_FILE.to_string()];
1350        for planning in [false, true] {
1351            let guidance =
1352                generate_runtime_tool_guidelines_for_profile(&tools, planning, ResolvedShellPromptProfile::UnixLike);
1353            assert_eq!(guidance.matches(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE).count(), 1);
1354            assert!(guidance.contains("report skipped"));
1355            assert!(guidance.contains("without installing tools"));
1356            assert!(guidance.contains("Lint errors remain failures"));
1357        }
1358    }
1359
1360    #[test]
1361    fn planning_workflow_runtime_guidance_keeps_exec_read_only() {
1362        let tools = vec![
1363            TOOL_APPLY_PATCH.to_string(),
1364            TOOL_EXEC_COMMAND.to_string(),
1365            TOOL_CODE_SEARCH.to_string(),
1366        ];
1367        let guidelines =
1368            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1369
1370        assert!(guidelines.contains("Planning workflow active"));
1371        assert!(guidelines.contains("`exec_command` only for read-only verification"));
1372        assert!(!guidelines.contains("<proposed_plan>"));
1373        assert!(!guidelines.contains("Every implementation step"));
1374        assert!(guidelines.contains("omit unused filters"));
1375        assert!(!guidelines.contains("Inspect before edit"));
1376    }
1377
1378    #[test]
1379    fn runtime_tool_guidance_uses_explicit_powershell_profile() {
1380        let tools = vec![
1381            TOOL_APPLY_PATCH.to_string(),
1382            TOOL_EXEC_COMMAND.to_string(),
1383            TOOL_CODE_SEARCH.to_string(),
1384        ];
1385        let guidelines =
1386            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::PowerShell);
1387
1388        assert!(guidelines.contains("native PowerShell commands"));
1389        assert!(guidelines.contains("`Get-ChildItem`"));
1390        assert!(guidelines.contains("`Select-String`"));
1391        assert!(guidelines.contains("native PowerShell syntax"));
1392        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1393    }
1394
1395    #[test]
1396    fn runtime_tool_guidance_uses_explicit_unix_like_profile() {
1397        let tools = vec![
1398            TOOL_APPLY_PATCH.to_string(),
1399            TOOL_EXEC_COMMAND.to_string(),
1400            TOOL_CODE_SEARCH.to_string(),
1401        ];
1402        let guidelines =
1403            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::UnixLike);
1404
1405        assert!(guidelines.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1406        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1407        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1408        assert!(guidelines.contains("literal smart-case"));
1409        assert!(guidelines.contains("shell-only tasks"));
1410        assert!(!guidelines.contains("native PowerShell commands"));
1411        assert!(!guidelines.contains("`Get-ChildItem`"));
1412    }
1413
1414    #[test]
1415    fn runtime_tool_prompt_sections_use_explicit_profile_for_active_tools() {
1416        let mut powershell_prompt = "Base prompt".to_string();
1417        let mut unix_prompt = "Base prompt".to_string();
1418        let snapshot = SessionToolCatalogSnapshot::new(
1419            7,
1420            9,
1421            false,
1422            false,
1423            Some(std::sync::Arc::new(vec![
1424                ToolDefinition::function(
1425                    TOOL_EXEC_COMMAND.to_string(),
1426                    "Shell".to_string(),
1427                    serde_json::json!({"type": "object"}),
1428                ),
1429                ToolDefinition::function(
1430                    TOOL_CODE_SEARCH.to_string(),
1431                    "Bounded source search".to_string(),
1432                    serde_json::json!({"type": "object"}),
1433                ),
1434            ])),
1435            false,
1436        );
1437
1438        append_runtime_tool_prompt_sections_for_profile(
1439            &mut powershell_prompt,
1440            &snapshot,
1441            false,
1442            ResolvedShellPromptProfile::PowerShell,
1443        );
1444        append_runtime_tool_prompt_sections_for_profile(
1445            &mut unix_prompt,
1446            &snapshot,
1447            false,
1448            ResolvedShellPromptProfile::UnixLike,
1449        );
1450
1451        assert!(powershell_prompt.contains("## Active Tools"));
1452        assert!(powershell_prompt.contains("`Get-ChildItem`"));
1453        assert!(powershell_prompt.contains("`Select-String`"));
1454        assert!(!powershell_prompt.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1455
1456        assert!(unix_prompt.contains("## Active Tools"));
1457        assert!(unix_prompt.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1458        assert!(unix_prompt.contains("Prefer `code_search` over `rg`/`grep` for code"));
1459        assert!(unix_prompt.contains("Advanced `code_search` takes `query`"));
1460        assert!(unix_prompt.contains("literal smart-case"));
1461        assert!(!unix_prompt.contains("`Get-ChildItem`"));
1462    }
1463
1464    #[test]
1465    fn runtime_tool_prompt_sections_include_catalog_metadata() {
1466        let mut prompt = "Base prompt".to_string();
1467        let snapshot = SessionToolCatalogSnapshot::new(
1468            7,
1469            9,
1470            true,
1471            false,
1472            Some(std::sync::Arc::new(vec![
1473                ToolDefinition::function(
1474                    TOOL_EXEC_COMMAND.to_string(),
1475                    "Search".to_string(),
1476                    serde_json::json!({"type": "object"}),
1477                ),
1478                ToolDefinition::function(
1479                    TOOL_APPLY_PATCH.to_string(),
1480                    "File".to_string(),
1481                    serde_json::json!({"type": "object"}),
1482                ),
1483            ])),
1484            false,
1485        );
1486
1487        append_runtime_tool_prompt_sections_for_profile(
1488            &mut prompt,
1489            &snapshot,
1490            true,
1491            ShellPromptProfile::Auto.resolve_for_current_platform(),
1492        );
1493
1494        assert!(prompt.contains("## Active Tools"));
1495        assert!(prompt.contains("[Runtime Tool Catalog]"));
1496        assert!(prompt.contains("catalog_tools: 2"));
1497        assert!(prompt.contains("currently_available_tools: exec_command, apply_patch"));
1498        assert!(prompt.contains("request_user_input_enabled: false"));
1499    }
1500
1501    #[test]
1502    fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
1503        let mut prompt = "Base prompt".to_string();
1504        let first = SessionToolCatalogSnapshot::new(
1505            1,
1506            2,
1507            false,
1508            false,
1509            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1510                TOOL_EXEC_COMMAND.to_string(),
1511                "Search".to_string(),
1512                serde_json::json!({"type": "object"}),
1513            )])),
1514            false,
1515        );
1516        let second = SessionToolCatalogSnapshot::new(
1517            7,
1518            9,
1519            true,
1520            true,
1521            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1522                TOOL_APPLY_PATCH.to_string(),
1523                "File".to_string(),
1524                serde_json::json!({"type": "object"}),
1525            )])),
1526            false,
1527        );
1528
1529        let shell = ShellPromptProfile::Auto.resolve_for_current_platform();
1530        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &first, true, shell);
1531        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &second, true, shell);
1532
1533        assert_eq!(prompt.matches("## Active Tools").count(), 1);
1534        assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
1535        assert!(prompt.contains("version: 7"));
1536        assert!(!prompt.contains("version: 1"));
1537        assert!(prompt.contains("request_user_input_enabled: true"));
1538        assert!(!prompt.contains("request_user_input_enabled: false"));
1539    }
1540}