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 = "- `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
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    record_decision: bool,
121}
122
123impl ToolPresence {
124    fn of(available_tools: &[String]) -> Self {
125        // Single pass over the tool list: the old form ran one linear
126        // `any()` scan per flag (10 scans per call, up to 4 calls per
127        // model-aware prompt build). Same membership policy, one walk.
128        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
161/// Render from resolved capabilities, without changing tool authorization.
162pub(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            // Safeguard, verification, wait-instead-of-poll, and spool/preview
214            // rules already ship in Runtime Guidance.
215            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
226/// Generate compact cross-tool guidance with an explicit shell prompt profile.
227/// `parallel_tools` mirrors the provider's parallel-tool configuration: when
228/// false, the parallel-call hint is not emitted instead of stripped post hoc.
229pub(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        // Verifier discipline: the anti-blind-editing gate only clears on a
271        // truthful exit 0. Runtime owns truncator elision and unverified
272        // classification (`tool_intent/activity.rs`, spool processing); the
273        // prompt keeps only the outcome rule so wording cannot drift from
274        // enforcement.
275        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        // Low-effort models sometimes report a change as done without
277        // exercising it: require a real check (tests, type-checker, build,
278        // or the changed command itself). A syntax-only check, or a check
279        // command that failed to start, does not count. Applies to Sonnet
280        // 5.5 at `low` effort and any route where verification is skipped.
281        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        // Tool-latency tail is dominated by full builds (observed p90 ~18s):
283        // verify incrementally first. Kept tool-agnostic: fast checks exist
284        // in every stack (`cargo check`, `tsc --noEmit`, `pytest --collect-only`).
285        lines.push("- Run fast checks before full builds.".to_string());
286    }
287    // Tool-failure diagnosis, waiting on returned `next_wait_args` instead of
288    // polling, the safeguard rule, the verification outcome rule (report
289    // completion only after a check you ran), and spool paging with
290    // per-result preview bounds each have one home in Runtime
291    // Guidance, which every profile includes; do not restate them here.
292    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
334/// Select documentation density using the active route and session budgets.
335pub 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    // The planning mode section owns the output contract at every density.
357    // Default planning tool guidance contains no parallel-call hint.
358    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
390/// Replace the runtime tool sections with guidance built from `names`, plus
391/// optional catalog metadata. Return the Default guidance so model-aware
392/// callers can reuse it after measuring the assembled prompt.
393fn 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
431/// Compact tool reminders; the planning mode section retains the canonical
432/// research, step-quality, output, and persistence contract at every density.
433fn 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
457/// Append a compact summary of tools omitted from a client-local wire payload.
458///
459/// The listing is bounded like the `## Skills` routing section: at most
460/// `DEFERRED_TOOLS_MAX_GROUPS` groups are named and the rest collapse into one
461/// overflow line, so MCP-heavy sessions cannot bloat every request. Group
462/// descriptions are server-provided and unbounded, so each line is truncated
463/// to `DEFERRED_TOOLS_MAX_DESC_CHARS` characters.
464pub 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, &section);
502}
503
504/// Maximum deferred-tool groups named in the `[Deferred Tools]` prompt
505/// section before the rest collapse into one overflow line.
506const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
507/// Maximum characters of a group description in the section. Mirrors the
508/// `## Skills` line truncation: server-provided text must not bloat the prompt.
509const 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        // Measurement parity: this Default-density build feeds the prompt size
548        // that `ToolGuidanceProfile::resolve` reads, so it keeps shipping the
549        // parallel-call hint unconditionally like the static composition path.
550        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    // Sessions show `exec_command` + `rg` crowding out `code_search`
634    // (observed 654 exec vs 15 code_search in one run): `rg` scans text
635    // while `code_search` resolves definitions and exact usages, so route
636    // code search to the dedicated tool whenever it is available.
637    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
710/// Infer capability level from available tools.
711pub(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    /// Default-density guidance with the platform shell profile and the
734    /// parallel-call hint, matching what static composition ships.
735    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    /// Reference the previous model-aware assembly: measure Default guidance,
745    /// then regenerate it with resolved density and capabilities.
746    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    /// Universal rules have one home in Runtime Guidance (or the shared
923    /// contract). Compose every static profile with each tool-guidance variant
924    /// and the Harness Limits section, and check each rule marker lands once.
925    #[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        // Each marker names one universal rule that lives in Runtime Guidance
979        // or the shared contract and must not be restated by tool sections.
980        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        // Search steering: with both tools present the browse line must
1149        // prefer `code_search` over `rg` for code (session evidence showed
1150        // `rg`-via-exec crowding out `code_search` 654:15).
1151        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1152        // Latency steering: fast checks before full builds (tool-agnostic).
1153        assert!(guidelines.contains("Run fast checks before full builds"));
1154        // Completion-as-checkpoint guidance lives in the operating profiles;
1155        // the guidelines section no longer repeats it.
1156        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        // Completion-as-checkpoint guidance lives in the operating profiles;
1166        // the guidelines section no longer repeats it.
1167        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        // The planning sidecar rejects index 0, so no planning line may present
1213        // it as a valid checklist-completion index.
1214        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        // Cross-turn resume (invariant #22): the live id arrives via a
1235        // turn-start `Exec session resume:` hint when a prior turn ended mid-run.
1236        assert!(guidelines.contains("`Exec session resume:`"));
1237        assert!(guidelines.contains("prior turn ended mid-run"));
1238        // No `code_search` in this profile: the search-preference clause
1239        // must not spend budget naming an unavailable tool.
1240        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        // Exec-capable profiles describe the fail-closed verifier pipeline
1465        // contract, including the terminal outcome and rejected shell forms.
1466        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        // The batching, bounded-diff, and verifier-discipline guardrails are
1474        // intentionally part of the compact shared prompt. Raised from 500 so
1475        // the verifier rule can state its reason (a visible exit status).
1476        // Raised to 580 for the Sonnet 5.5 low-effort real-check rule
1477        // (syntax-only/failed-to-start do not count; project package manager,
1478        // never sudo; state which check was skipped and why).
1479        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        // One group with an unbounded server-provided description.
1505        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        // Idempotent: re-appending replaces the section instead of duplicating it.
1523        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        // No exec tools in this profile: the verifier truthful-status rule is
1568        // exec-conditional and must not spend budget here.
1569        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        // Without the tool, no profile mentions it.
1587        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}