Skip to main content

vtcode_core/prompts/
guidelines.rs

1use crate::config::constants::tools;
2use crate::config::types::{CapabilityLevel, ResolvedShellPromptProfile};
3use crate::core::agent::harness_kernel::SessionToolCatalogSnapshot;
4use crate::llm::provider::ToolDefinition;
5use crate::prompts::sections::{
6    SectionBoundaryMode, append_prompt_block, remove_prompt_section, trim_trailing_newlines,
7};
8use crate::tools::registry::tool_groups;
9
10const TOOL_EXEC_COMMAND: &str = tools::EXEC_COMMAND;
11const TOOL_WRITE_STDIN: &str = tools::WRITE_STDIN;
12const TOOL_CODE_SEARCH: &str = tools::CODE_SEARCH;
13const TOOL_READ_FILE: &str = tools::READ_FILE;
14const TOOL_LIST_FILES: &str = tools::LIST_FILES;
15const TOOL_GREP_FILE: &str = tools::GREP_FILE;
16const TOOL_APPLY_PATCH: &str = tools::APPLY_PATCH;
17const TOOL_REQUEST_USER_INPUT: &str = tools::REQUEST_USER_INPUT;
18const TOOL_TASK_TRACKER: &str = tools::TASK_TRACKER;
19const SOURCE_INSPECTION_GUIDANCE: &str =
20    "- Inspect relevant source, edit a bounded region, refresh after mismatch, then verify.";
21const 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.";
22const 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.";
23const TOOL_START_PLANNING: &str = tools::START_PLANNING;
24
25const OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE: &str = "- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.";
26
27/// Shared cross-turn resume pointer (invariant #22). The hint body itself stays
28/// transient via `append_transient_turn_notes`; tool guidance only advertises
29/// that a turn-start `Exec session resume:` note carries the live ids so a
30/// resumed or compacted session needs zero identity reconstruction.
31const CROSS_TURN_RESUME_HINT_CLAUSE: &str =
32    "; a turn-start `Exec session resume:` hint carries the live ids when a prior turn ended mid-run.";
33
34/// The single home of `start_planning` guidance, shared by the Minimal and
35/// Default Active Tools. The tool itself asks the user before entering
36/// planning (`require_confirmation`), except under full-auto or
37/// skip-confirmations, so the model calls it rather than proposing it in prose.
38const 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.";
39
40/// Planning-workflow `task_tracker` index rules. While planning, the tracker
41/// routes to the plan sidecar, which rejects index 0 (`index_path components
42/// must be >= 1`); checklist-level `index: 0` completion exists only outside
43/// planning, so neither line advertises it.
44const 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.";
45const 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.";
46
47/// Context-token ceiling at or below which Active Tools documentation
48/// resolves to the Minimal density.
49const MINIMAL_CONTEXT_TOKEN_CEILING: usize = 32_000;
50
51/// The single home of the parallel-call hint, shared by the Minimal and
52/// Default Active Tools; emitted only when the provider keeps parallel tool
53/// configuration enabled.
54const PARALLEL_TOOLS_LINE: &str = "- Run independent tools in parallel when inputs do not depend on each other.";
55
56/// The single home of the `code_search` filter hygiene rule, shared by the
57/// Minimal, Default, and planning Active Tools variants.
58const CODE_SEARCH_FILTER_LINE: &str = "- `code_search`: omit unused filters; no empty values (`path: \"\"`).";
59
60/// The single home of the `request_user_input` threshold rule, shared by the
61/// Minimal planning addendum and the planning Active Tools.
62const REQUEST_USER_INPUT_LINE: &str =
63    "- Use `request_user_input` only for material blockers remaining after repository exploration.";
64
65/// Read-only capability line shared by the capability-level and
66/// tool-fallback arms of `capability_mode_line`.
67const CAPABILITY_READ_ONLY_LINE: &str =
68    "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.";
69
70/// Shared `write_stdin` recovery clause; each profile adds its own
71/// rerun/wait policy around it.
72const WRITE_STDIN_RECOVERY_CLAUSE: &str = "; if missing, recover prior output";
73
74/// Policy lines shared by both shell profiles: the profile governs syntax
75/// examples only, and VT Code never translates flags across shells.
76const 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.";
77
78/// Documentation density is independent of the tools a session may execute.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub(crate) enum ToolGuidanceProfile {
81    Minimal,
82    Default,
83}
84
85impl ToolGuidanceProfile {
86    #[must_use]
87    pub(crate) fn resolve(
88        context_tokens: usize,
89        default_prompt_tokens: usize,
90        max_prompt_tokens: usize,
91        input_usd_per_token: Option<f64>,
92        max_budget_usd: Option<f64>,
93    ) -> Self {
94        let exceeds_cost = input_usd_per_token.zip(max_budget_usd).is_some_and(|(price, budget)| {
95            price.is_finite() && price >= 0.0 && budget.is_finite() && default_prompt_tokens as f64 * price > budget
96        });
97        if (context_tokens > 0 && context_tokens <= MINIMAL_CONTEXT_TOKEN_CEILING)
98            || (max_prompt_tokens > 0 && default_prompt_tokens > max_prompt_tokens)
99            || exceeds_cost
100        {
101            Self::Minimal
102        } else {
103            Self::Default
104        }
105    }
106}
107
108/// Which guidance-gating tools a session exposes, computed once per build so
109/// every Active Tools variant branches on the same membership policy.
110struct ToolPresence {
111    exec: bool,
112    write_stdin: bool,
113    code_search: bool,
114    read_file: bool,
115    grep_file: bool,
116    list_files: bool,
117    apply_patch: bool,
118    request_user_input: bool,
119    task_tracker: bool,
120    start_planning: bool,
121    matrix: bool,
122    record_decision: bool,
123}
124
125impl ToolPresence {
126    fn of(available_tools: &[String]) -> Self {
127        // Single pass over the tool list: the old form ran one linear
128        // `any()` scan per flag (10 scans per call, up to 4 calls per
129        // model-aware prompt build). Same membership policy, one walk.
130        let mut presence = Self {
131            exec: false,
132            write_stdin: false,
133            code_search: false,
134            read_file: false,
135            grep_file: false,
136            list_files: false,
137            apply_patch: false,
138            request_user_input: false,
139            task_tracker: false,
140            start_planning: false,
141            matrix: false,
142            record_decision: false,
143        };
144        for tool in available_tools {
145            match tool.as_str() {
146                TOOL_EXEC_COMMAND => presence.exec = true,
147                TOOL_WRITE_STDIN => presence.write_stdin = true,
148                TOOL_CODE_SEARCH => presence.code_search = true,
149                TOOL_READ_FILE => presence.read_file = true,
150                TOOL_GREP_FILE => presence.grep_file = true,
151                TOOL_LIST_FILES => presence.list_files = true,
152                TOOL_APPLY_PATCH => presence.apply_patch = true,
153                TOOL_REQUEST_USER_INPUT => presence.request_user_input = true,
154                TOOL_TASK_TRACKER => presence.task_tracker = true,
155                TOOL_START_PLANNING => presence.start_planning = true,
156                "matrix" => presence.matrix = true,
157                "record_decision" => presence.record_decision = true,
158                _ => {}
159            }
160        }
161        presence
162    }
163}
164
165/// Render from resolved capabilities, without changing tool authorization.
166pub(crate) fn generate_tool_guidelines_with_capabilities(
167    available_tools: &[String],
168    capability_level: Option<CapabilityLevel>,
169    shell_profile: ResolvedShellPromptProfile,
170    profile: ToolGuidanceProfile,
171    parallel_tools: bool,
172) -> String {
173    match profile {
174        ToolGuidanceProfile::Default => {
175            generate_tool_guidelines_for_profile(available_tools, capability_level, shell_profile, parallel_tools)
176        }
177        ToolGuidanceProfile::Minimal => {
178            if available_tools.is_empty() {
179                return String::new();
180            }
181            let presence = ToolPresence::of(available_tools);
182            let mut lines = vec!["\n\n## Active Tools".to_owned()];
183            if presence.apply_patch {
184                lines.push(SOURCE_INSPECTION_GUIDANCE.to_owned());
185            }
186            if presence.matrix {
187                lines.push(MATRIX_GUIDANCE.to_owned());
188            }
189            if presence.record_decision {
190                lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
191            }
192            lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_owned());
193            if let Some(mode) = capability_mode_line(capability_level, presence.exec, presence.apply_patch) {
194                lines.push(mode.to_owned());
195            }
196            if let Some(browse) = browse_tool_guidance(
197                presence.exec,
198                presence.code_search,
199                presence.list_files,
200                presence.read_file,
201                shell_profile,
202            ) {
203                lines.push(browse);
204            }
205            if presence.code_search {
206                lines.push(CODE_SEARCH_FILTER_LINE.to_owned());
207            }
208            if presence.apply_patch {
209                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());
210            }
211            if presence.exec {
212                lines.push(shell_task_guidance(shell_profile).to_owned());
213                lines.push(background_exec_guidance().to_owned());
214            }
215            if presence.write_stdin {
216                lines.push(format!(
217                    "- `write_stdin`: use returned `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; repeat waits after in-progress deadlines{CROSS_TURN_RESUME_HINT_CLAUSE}"
218                ));
219            }
220            // Safeguard, verification, wait-instead-of-poll, and spool/preview
221            // rules already ship in Runtime Guidance.
222            if presence.start_planning {
223                lines.push(START_PLANNING_GUIDANCE_LINE.to_owned());
224            }
225            if parallel_tools {
226                lines.push(PARALLEL_TOOLS_LINE.to_owned());
227            }
228            lines.join("\n")
229        }
230    }
231}
232
233/// Generate compact cross-tool guidance with an explicit shell prompt profile.
234/// `parallel_tools` mirrors the provider's parallel-tool configuration: when
235/// false, the parallel-call hint is not emitted instead of stripped post hoc.
236pub(crate) fn generate_tool_guidelines_for_profile(
237    available_tools: &[String],
238    capability_level: Option<CapabilityLevel>,
239    shell_profile: ResolvedShellPromptProfile,
240    parallel_tools: bool,
241) -> String {
242    let presence = ToolPresence::of(available_tools);
243    let has_exec = presence.exec;
244    let has_stdin = presence.write_stdin;
245    let has_search = presence.code_search;
246    let has_read_file = presence.read_file;
247    let has_list_files = presence.list_files;
248    let has_apply_patch = presence.apply_patch;
249    let has_start_planning = presence.start_planning;
250
251    let mut lines = Vec::new();
252    if has_apply_patch {
253        lines.push(SOURCE_INSPECTION_GUIDANCE.to_owned());
254    }
255    if presence.matrix {
256        lines.push(MATRIX_GUIDANCE.to_owned());
257    }
258    lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
259    if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_apply_patch) {
260        lines.push(mode_line.to_string());
261    }
262    if let Some(browse_guidance) =
263        browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
264    {
265        lines.push(browse_guidance);
266    }
267    if has_search || has_read_file || has_list_files {
268        lines.push(read_only_batching_guidance(has_read_file).to_string());
269    }
270    if has_apply_patch {
271        lines.push("- Use `apply_patch` for file edits after inspection; keep patches small.".to_string());
272        lines.push(
273            "- Check that each diff stays bounded. WebMCP edits are untrusted proposals, and terminal permission stays authoritative."
274                .to_string(),
275        );
276    }
277    if has_exec {
278        lines.push(shell_task_guidance(shell_profile).to_string());
279        lines.push(background_exec_guidance().to_string());
280        // Verifier discipline: the anti-blind-editing gate only clears on a
281        // truthful exit 0. Runtime owns truncator elision and unverified
282        // classification (`tool_intent/activity.rs`, spool processing); the
283        // prompt keeps only the outcome rule so wording cannot drift from
284        // enforcement.
285        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. Recheck after `--fix`.".to_string());
286        // Low-effort models sometimes report a change as done without
287        // exercising it: require a real check (tests, type-checker, build,
288        // or the changed command itself). A syntax-only check, or a check
289        // command that failed to start, does not count. Applies to Sonnet
290        // 5.5 at `low` effort and any route where verification is skipped.
291        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());
292        // Tool-latency tail is dominated by full builds (observed p90 ~18s):
293        // verify incrementally first. Kept tool-agnostic: fast checks exist
294        // in every stack (`cargo check`, `tsc --noEmit`, `pytest --collect-only`).
295        lines.push("- Run fast checks before full builds.".to_string());
296    }
297    // Tool-failure diagnosis, waiting on returned `next_wait_args` instead of
298    // polling, the safeguard rule, the verification outcome rule (report
299    // completion only after a check you ran), and spool paging with
300    // per-result preview bounds each have one home in Runtime
301    // Guidance, which every profile includes; do not restate them here.
302    if has_stdin {
303        lines.push(format!(
304            "- `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}"
305        ));
306    }
307    if has_search {
308        lines.push(CODE_SEARCH_FILTER_LINE.to_string());
309        lines.push(code_search_guidance(has_exec));
310    }
311    if has_apply_patch || has_exec {
312        lines.push(
313            "- Build and Auto share tools and safety gates; Auto changes confirmation behavior only after explicit approval or full-auto policy."
314                .to_string(),
315        );
316    }
317    if (has_search || has_exec) && parallel_tools {
318        lines.push(PARALLEL_TOOLS_LINE.to_string());
319    }
320    if has_start_planning {
321        lines.push(START_PLANNING_GUIDANCE_LINE.to_string());
322    }
323
324    if lines.is_empty() {
325        return String::new();
326    }
327
328    if presence.record_decision {
329        lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
330    }
331    format!("\n\n## Active Tools\n{}", lines.join("\n"))
332}
333
334pub fn append_runtime_tool_prompt_sections_for_profile(
335    prompt: &mut String,
336    tool_snapshot: &SessionToolCatalogSnapshot,
337    include_catalog_metadata: bool,
338    shell_profile: ResolvedShellPromptProfile,
339) {
340    let names = snapshot_tool_names(tool_snapshot);
341    append_runtime_tool_sections_with_names(prompt, tool_snapshot, names, include_catalog_metadata, shell_profile);
342}
343
344/// Select documentation density using the active route and session budgets.
345pub fn append_runtime_tool_prompt_sections_for_model(
346    prompt: &mut String,
347    tool_snapshot: &SessionToolCatalogSnapshot,
348    include_catalog_metadata: bool,
349    shell_profile: ResolvedShellPromptProfile,
350    provider: &dyn crate::llm::provider::LLMProvider,
351    model: &str,
352    config: Option<&crate::config::VTCodeConfig>,
353) {
354    let names = snapshot_tool_names(tool_snapshot);
355    let default_guidance =
356        append_runtime_tool_sections_with_names(prompt, tool_snapshot, names, include_catalog_metadata, shell_profile);
357    let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
358    let profile = ToolGuidanceProfile::resolve(
359        crate::compaction::effective_context_budget(config, provider, model),
360        vtcode_commons::estimate_tokens(prompt),
361        config.map_or(0, |cfg| cfg.agent.max_system_prompt_tokens as usize),
362        pricing.and_then(|price| price.input),
363        config.and_then(|cfg| cfg.agent.harness.max_budget_usd),
364    );
365    let parallel_tools = provider.supports_parallel_tool_config(model);
366    // The planning mode section owns the output contract at every density.
367    // Default planning tool guidance contains no parallel-call hint.
368    if tool_snapshot.planning_active && profile == ToolGuidanceProfile::Default {
369        return;
370    }
371    remove_prompt_section(prompt, "## Active Tools", SectionBoundaryMode::BracketOrMarkdown);
372    let presence = ToolPresence::of(names);
373    let capability_level = Some(infer_capability_level_from_presence(&presence));
374    let mut guidance = match profile {
375        ToolGuidanceProfile::Default => {
376            let mut guidance = default_guidance;
377            if let (Some(original), Some(effective)) = (
378                capability_mode_line(None, presence.exec, presence.apply_patch),
379                capability_mode_line(capability_level, presence.exec, presence.apply_patch),
380            ) && original != effective
381                && let Some(start) = guidance.find(original)
382            {
383                guidance.replace_range(start..start + original.len(), effective);
384            }
385            if !parallel_tools && let Some(start) = guidance.find(PARALLEL_TOOLS_LINE) {
386                guidance.replace_range(start.saturating_sub(1)..start + PARALLEL_TOOLS_LINE.len(), "");
387            }
388            guidance
389        }
390        ToolGuidanceProfile::Minimal => {
391            generate_tool_guidelines_with_capabilities(names, capability_level, shell_profile, profile, parallel_tools)
392        }
393    };
394    if tool_snapshot.planning_active {
395        append_minimal_planning_addendum(&mut guidance, &presence);
396    }
397    append_prompt_block(prompt, guidance.trim_start_matches('\n'));
398}
399
400/// Replace the runtime tool sections with guidance built from `names`, plus
401/// optional catalog metadata. Return the Default guidance so model-aware
402/// callers can reuse it after measuring the assembled prompt.
403fn append_runtime_tool_sections_with_names(
404    prompt: &mut String,
405    tool_snapshot: &SessionToolCatalogSnapshot,
406    names: &[String],
407    include_catalog_metadata: bool,
408    shell_profile: ResolvedShellPromptProfile,
409) -> String {
410    remove_prompt_section(prompt, "## Active Tools", SectionBoundaryMode::BracketOrMarkdown);
411    remove_prompt_section(prompt, "[Runtime Tool Catalog]", SectionBoundaryMode::BracketOrMarkdown);
412    trim_trailing_newlines(prompt);
413
414    let guidelines = generate_runtime_tool_guidelines_for_profile(names, tool_snapshot.planning_active, shell_profile);
415    if !guidelines.is_empty() {
416        append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
417    }
418
419    if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
420        let active_tools = if tool_snapshot.active_tool_names.is_empty() {
421            "none".to_string()
422        } else {
423            tool_snapshot.active_tool_names.join(", ")
424        };
425        let catalog_metadata = format!(
426            "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
427            tool_snapshot.version,
428            tool_snapshot.epoch,
429            tool_snapshot.catalog_tools(),
430            tool_snapshot.available_tools(),
431            active_tools,
432            tool_snapshot.request_user_input_enabled,
433        );
434        append_prompt_block(prompt, &catalog_metadata);
435    }
436    guidelines
437}
438
439/// Compact tool reminders; the planning mode section retains the canonical
440/// research, step-quality, output, and persistence contract at every density.
441fn append_minimal_planning_addendum(guidance: &mut String, presence: &ToolPresence) {
442    guidance.push_str("\n- Planning is read-only.");
443    let mut read_tools = Vec::with_capacity(4);
444    if presence.read_file {
445        read_tools.push(TOOL_READ_FILE);
446    }
447    if presence.grep_file {
448        read_tools.push(TOOL_GREP_FILE);
449    }
450    if presence.code_search {
451        read_tools.push(TOOL_CODE_SEARCH);
452    }
453    if presence.list_files {
454        read_tools.push(TOOL_LIST_FILES);
455    }
456    guidance.push_str("\n- Keep inspections small: ");
457    if !read_tools.is_empty() {
458        guidance.push_str(&format!("prefer `{}` over `exec_command` shell reads; ", read_tools.join("`/`")));
459    }
460    guidance.push_str("keep `max_output_tokens` small, avoid batching multiple large inspections in parallel; start git history with `git log --oneline` before targeted `git show --stat`.");
461    if presence.task_tracker {
462        guidance.push('\n');
463        guidance.push_str(PLANNING_TASK_TRACKER_COMPACT_LINE);
464    }
465    if presence.request_user_input {
466        guidance.push('\n');
467        guidance.push_str(REQUEST_USER_INPUT_LINE);
468    }
469}
470
471/// Append a compact summary of tools omitted from a client-local wire payload.
472///
473/// The listing is bounded like the `## Skills` routing section: at most
474/// `DEFERRED_TOOLS_MAX_GROUPS` groups are named and the rest collapse into one
475/// overflow line, so MCP-heavy sessions cannot bloat every request. Group
476/// descriptions are server-provided and unbounded, so each line is truncated
477/// to `DEFERRED_TOOLS_MAX_DESC_CHARS` characters.
478pub fn append_deferred_tools_prompt_section(prompt: &mut String, tools: &[ToolDefinition]) {
479    remove_prompt_section(prompt, "[Deferred Tools]", SectionBoundaryMode::BracketOrMarkdown);
480
481    let mut groups: Vec<_> = tool_groups(tools)
482        .into_iter()
483        .filter(|group| group.deferred_count > 0)
484        .collect();
485    let overflow = groups.len().saturating_sub(DEFERRED_TOOLS_MAX_GROUPS);
486    groups.truncate(DEFERRED_TOOLS_MAX_GROUPS);
487
488    let mut lines: Vec<String> = groups
489        .into_iter()
490        .map(|group| {
491            let description = truncate_deferred_description(group.description.as_deref().unwrap_or_default());
492            format!("- {} ({} tools): {}", group.name, group.deferred_count, description)
493        })
494        .collect();
495    if overflow > 0 {
496        lines.push(format!("(+{overflow} more deferred groups available — use `search_tools` to find them)"));
497    }
498
499    let unnamespaced_deferred = tools
500        .iter()
501        .filter(|tool| tool.namespace.is_none() && tool.defer_loading == Some(true))
502        .count();
503    if unnamespaced_deferred > 0 {
504        lines.push(format!("- {unnamespaced_deferred} additional deferred tools"));
505    }
506
507    if lines.is_empty() {
508        return;
509    }
510
511    let section = format!(
512        "[Deferred Tools]\n{}\nUse `search_tools` to find a deferred capability. Selected definitions become available in the next request segment.",
513        lines.join("\n")
514    );
515    append_prompt_block(prompt, &section);
516}
517
518/// Maximum deferred-tool groups named in the `[Deferred Tools]` prompt
519/// section before the rest collapse into one overflow line.
520const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
521/// Maximum characters of a group description in the section. Mirrors the
522/// `## Skills` line truncation: server-provided text must not bloat the prompt.
523const DEFERRED_TOOLS_MAX_DESC_CHARS: usize = 120;
524
525fn truncate_deferred_description(description: &str) -> String {
526    // Single pass: visit at most `MAX + 1` chars. The old form ran
527    // `chars().count()` over the whole string, then `chars().take()` again.
528    let mut chars = description.chars();
529    let truncated: String = chars.by_ref().take(DEFERRED_TOOLS_MAX_DESC_CHARS).collect();
530    if chars.next().is_none() {
531        return description.to_string();
532    }
533    format!("{}...", truncated.trim_end())
534}
535
536fn generate_runtime_tool_guidelines_for_profile(
537    available_tools: &[String],
538    planning_active: bool,
539    shell_profile: ResolvedShellPromptProfile,
540) -> String {
541    if !planning_active {
542        // Measurement parity: this Default-density build feeds the prompt size
543        // that `ToolGuidanceProfile::resolve` reads, so it keeps shipping the
544        // parallel-call hint unconditionally like the static composition path.
545        return generate_tool_guidelines_for_profile(available_tools, None, shell_profile, true);
546    }
547
548    let presence = ToolPresence::of(available_tools);
549    let has_exec = presence.exec;
550    let has_search = presence.code_search;
551    let has_read_file = presence.read_file;
552    let has_list_files = presence.list_files;
553    let has_request_user_input = presence.request_user_input;
554    let has_task_tracker = presence.task_tracker;
555
556    let mut lines = vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
557    lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
558    if let Some(browse_guidance) =
559        browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
560    {
561        lines.push(browse_guidance);
562    }
563    if has_exec {
564        lines.push("- In Planning workflow, use `exec_command` only for read-only verification.".to_string());
565    }
566    if has_search {
567        lines.push(CODE_SEARCH_FILTER_LINE.to_string());
568    }
569    if has_task_tracker {
570        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());
571        lines.push("- Keep blockers and verification open in `task_tracker` until resolved.".to_string());
572        lines.push(PLANNING_TASK_TRACKER_INDEX_LINE.to_string());
573    }
574    if has_request_user_input {
575        lines.push(REQUEST_USER_INPUT_LINE.to_string());
576    }
577    if has_search || has_exec {
578        lines.push("- If calls repeat without progress, tighten the plan instead of retrying identically.".to_string());
579    }
580    if presence.record_decision {
581        lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
582    }
583
584    format!("\n\n## Active Tools\n{}", lines.join("\n"))
585}
586
587fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> &[String] {
588    &tool_snapshot.active_tool_names
589}
590
591fn browse_tool_guidance(
592    has_exec: bool,
593    has_search: bool,
594    has_list_files: bool,
595    has_read_file: bool,
596    shell_profile: ResolvedShellPromptProfile,
597) -> Option<String> {
598    if has_exec {
599        return Some(shell_browse_guidance(shell_profile, has_search));
600    }
601
602    if !(has_search || has_list_files || has_read_file) {
603        return None;
604    }
605
606    Some("- Use available read-only repository tools for browsing; do not modify files.".to_string())
607}
608
609pub(crate) fn render_shell_profile_guidance(shell_profile: ResolvedShellPromptProfile) -> String {
610    let profile_lines = match shell_profile {
611        ResolvedShellPromptProfile::UnixLike => {
612            "- 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."
613        }
614        ResolvedShellPromptProfile::PowerShell => {
615            "- 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."
616        }
617    };
618    format!("## Shell Profile\n{profile_lines}\n{SHELL_PROFILE_POLICY_SUFFIX}")
619}
620
621fn shell_browse_guidance(shell_profile: ResolvedShellPromptProfile, has_search: bool) -> String {
622    // Sessions show `exec_command` + `rg` crowding out `code_search`
623    // (observed 654 exec vs 15 code_search in one run): `rg` scans text
624    // while `code_search` resolves definitions and exact usages, so route
625    // code search to the dedicated tool whenever it is available.
626    const SEARCH_PREFERENCE_UNIX: &str = " Prefer `code_search` over `rg`/`grep` for code.";
627    const SEARCH_PREFERENCE_POWERSHELL: &str = " Prefer `code_search` over `Select-String` for code.";
628    match shell_profile {
629        ResolvedShellPromptProfile::UnixLike => {
630            let mut line = String::from("- Use `exec_command.cmd` with `ls`, ");
631            if !has_search {
632                line.push_str("`rg`, ");
633            }
634            line.push_str("`find`, `cat`, `sed`, and `awk` for repository browsing.");
635            if has_search {
636                line.push_str(SEARCH_PREFERENCE_UNIX);
637            }
638            line
639        }
640        ResolvedShellPromptProfile::PowerShell => {
641            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();
642            if has_search {
643                line.push_str(SEARCH_PREFERENCE_POWERSHELL);
644            }
645            line
646        }
647    }
648}
649
650fn shell_task_guidance(shell_profile: ResolvedShellPromptProfile) -> &'static str {
651    match shell_profile {
652        ResolvedShellPromptProfile::UnixLike => {
653            "- 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`)."
654        }
655        ResolvedShellPromptProfile::PowerShell => {
656            "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks using native PowerShell syntax."
657        }
658    }
659}
660
661fn background_exec_guidance() -> &'static str {
662    "- 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."
663}
664
665fn read_only_batching_guidance(has_read_file: bool) -> &'static str {
666    if has_read_file {
667        "- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`; prefer wider windows and reuse evidence over overlapping slices."
668    } else {
669        "- Batch independent read-only calls; order dependent reads, and serialize mutations."
670    }
671}
672
673fn code_search_guidance(has_exec: bool) -> String {
674    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; unavailable: do not retry identical, drop `file_types`/broaden `path`. 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/`.";
675    if has_exec {
676        format!("{BASE} Use `exec_command` or a skill for syntax patterns.")
677    } else {
678        BASE.to_string()
679    }
680}
681
682fn capability_mode_line(
683    capability_level: Option<CapabilityLevel>,
684    has_exec: bool,
685    has_file: bool,
686) -> Option<&'static str> {
687    match capability_level {
688        Some(CapabilityLevel::Basic) => {
689            Some("- Capabilities: limited. Ask the user to enable more capabilities if file work is required.")
690        }
691        Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(CAPABILITY_READ_ONLY_LINE),
692        _ if !has_exec && !has_file => Some(CAPABILITY_READ_ONLY_LINE),
693        _ => None,
694    }
695}
696
697/// Infer capability level from available tools.
698pub(crate) fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
699    infer_capability_level_from_presence(&ToolPresence::of(available_tools))
700}
701
702fn infer_capability_level_from_presence(presence: &ToolPresence) -> CapabilityLevel {
703    if presence.code_search {
704        CapabilityLevel::CodeSearch
705    } else if presence.apply_patch {
706        CapabilityLevel::Editing
707    } else if presence.exec {
708        CapabilityLevel::Bash
709    } else if presence.list_files {
710        CapabilityLevel::FileListing
711    } else if presence.read_file {
712        CapabilityLevel::FileReading
713    } else {
714        CapabilityLevel::Basic
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::config::types::ShellPromptProfile;
722
723    /// Default-density guidance with the platform shell profile and the
724    /// parallel-call hint, matching what static composition ships.
725    fn guidelines_for(tools: &[String], capability_level: Option<CapabilityLevel>) -> String {
726        generate_tool_guidelines_for_profile(
727            tools,
728            capability_level,
729            ShellPromptProfile::Auto.resolve_for_current_platform(),
730            true,
731        )
732    }
733
734    /// Reference the previous model-aware assembly: measure Default guidance,
735    /// then regenerate it with resolved density and capabilities.
736    fn rebuilt_model_tool_prompt(
737        mut prompt: String,
738        snapshot: &SessionToolCatalogSnapshot,
739        metadata: bool,
740        shell: ResolvedShellPromptProfile,
741        provider: &dyn crate::llm::provider::LLMProvider,
742        model: &str,
743        config: &crate::config::VTCodeConfig,
744    ) -> String {
745        append_runtime_tool_prompt_sections_for_profile(&mut prompt, snapshot, metadata, shell);
746        let names = snapshot_tool_names(snapshot);
747        let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
748        let profile = ToolGuidanceProfile::resolve(
749            crate::compaction::effective_context_budget(Some(config), provider, model),
750            vtcode_commons::estimate_tokens(&prompt),
751            config.agent.max_system_prompt_tokens as usize,
752            pricing.and_then(|price| price.input),
753            config.agent.harness.max_budget_usd,
754        );
755        if snapshot.planning_active && profile == ToolGuidanceProfile::Default {
756            return prompt;
757        }
758        remove_prompt_section(&mut prompt, "## Active Tools", SectionBoundaryMode::BracketOrMarkdown);
759        let presence = ToolPresence::of(names);
760        let mut guidance = generate_tool_guidelines_with_capabilities(
761            names,
762            Some(infer_capability_level_from_presence(&presence)),
763            shell,
764            profile,
765            provider.supports_parallel_tool_config(model),
766        );
767        if snapshot.planning_active {
768            append_minimal_planning_addendum(&mut guidance, &presence);
769        }
770        append_prompt_block(&mut prompt, guidance.trim_start_matches('\n'));
771        prompt
772    }
773
774    #[test]
775    fn model_tool_prompt_reuse_matches_rebuild_across_density_and_capabilities() {
776        use crate::config::constants::models;
777        use crate::llm::provider::LLMProvider;
778        use crate::llm::providers::{AnthropicProvider, HuggingFaceProvider};
779
780        struct ProviderCase {
781            provider: Box<dyn LLMProvider>,
782            model: &'static str,
783            parallel: bool,
784        }
785        let providers = [
786            ProviderCase {
787                provider: Box::new(AnthropicProvider::new("offline-fixture".into())),
788                model: models::anthropic::DEFAULT_MODEL,
789                parallel: true,
790            },
791            ProviderCase {
792                provider: Box::new(HuggingFaceProvider::new("offline-fixture".into())),
793                model: "offline-fixture",
794                parallel: false,
795            },
796        ];
797        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";
798        for case in providers {
799            assert_eq!(case.provider.supports_parallel_tool_config(case.model), case.parallel);
800            for names in [
801                None,
802                Some(&[][..]),
803                Some(&[TOOL_TASK_TRACKER, TOOL_REQUEST_USER_INPUT][..]),
804                Some(&[TOOL_READ_FILE][..]),
805                Some(&[TOOL_WRITE_STDIN][..]),
806                Some(&[TOOL_EXEC_COMMAND, TOOL_APPLY_PATCH, TOOL_CODE_SEARCH, "record_decision"][..]),
807            ] {
808                for planning in [false, true] {
809                    let snapshot = SessionToolCatalogSnapshot::new(
810                        7,
811                        9,
812                        planning,
813                        true,
814                        names.map(|names| {
815                            std::sync::Arc::new(
816                                names
817                                    .iter()
818                                    .map(|name| {
819                                        ToolDefinition::function(
820                                            (*name).to_string(),
821                                            "fixture".to_string(),
822                                            serde_json::json!({"type": "object"}),
823                                        )
824                                    })
825                                    .collect(),
826                            )
827                        }),
828                        false,
829                    );
830                    for shell in [
831                        ResolvedShellPromptProfile::UnixLike,
832                        ResolvedShellPromptProfile::PowerShell,
833                    ] {
834                        for metadata in [false, true] {
835                            let mut measured = initial.to_string();
836                            append_runtime_tool_prompt_sections_for_profile(&mut measured, &snapshot, metadata, shell);
837                            let measured_tokens = vtcode_commons::estimate_tokens(&measured) as u64;
838                            for budget in [1, measured_tokens - 1, measured_tokens, 100_000] {
839                                let mut config = crate::config::VTCodeConfig::default();
840                                config.context.max_context_tokens = 128_000;
841                                config.agent.max_system_prompt_tokens = budget;
842                                let expected = rebuilt_model_tool_prompt(
843                                    initial.to_string(),
844                                    &snapshot,
845                                    metadata,
846                                    shell,
847                                    case.provider.as_ref(),
848                                    case.model,
849                                    &config,
850                                );
851                                let mut actual = initial.to_string();
852                                append_runtime_tool_prompt_sections_for_model(
853                                    &mut actual,
854                                    &snapshot,
855                                    metadata,
856                                    shell,
857                                    case.provider.as_ref(),
858                                    case.model,
859                                    Some(&config),
860                                );
861
862                                assert_eq!(
863                                    actual,
864                                    expected,
865                                    "provider={}, names={names:?}, planning={planning}, shell={shell:?}, metadata={metadata}, budget={budget}",
866                                    case.provider.name()
867                                );
868                                assert_eq!(
869                                    actual.matches("## Active Tools").count(),
870                                    usize::from(
871                                        budget >= measured_tokens || names.is_some_and(|names| !names.is_empty())
872                                    )
873                                );
874                                assert!(
875                                    actual.contains("# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.")
876                                );
877                                assert!(actual.contains("## Tail\nKeep this section."));
878                            }
879                        }
880                    }
881                }
882            }
883        }
884    }
885
886    #[test]
887    fn matrix_guidance_has_presence_and_budget_in_both_densities() {
888        for profile in [ToolGuidanceProfile::Default, ToolGuidanceProfile::Minimal] {
889            let enabled = generate_tool_guidelines_with_capabilities(
890                &["matrix".to_string(), "request_user_input".to_string()],
891                None,
892                ResolvedShellPromptProfile::UnixLike,
893                profile,
894                false,
895            );
896            let disabled = generate_tool_guidelines_with_capabilities(
897                &["request_user_input".to_string()],
898                None,
899                ResolvedShellPromptProfile::UnixLike,
900                profile,
901                false,
902            );
903            assert_eq!(enabled.matches(MATRIX_GUIDANCE).count(), 1);
904            assert!(enabled.contains("declared input changes"));
905            assert!(enabled.contains("effective permissions and sandbox gates"));
906            assert!(enabled.contains("Replay restores admission before discovery"));
907            assert!(enabled.contains("user cancel is terminal"));
908            assert!(!disabled.contains("scheduler-owned"));
909            assert!(vtcode_commons::estimate_tokens(&enabled) <= 220, "{profile:?}: {enabled}");
910        }
911    }
912
913    /// Universal rules have one home in Runtime Guidance (or the shared
914    /// contract). Compose every static profile with each tool-guidance variant
915    /// and the Harness Limits section, and check each rule marker lands once.
916    #[test]
917    fn universal_rules_have_one_home_across_composed_prompt_sections() {
918        use crate::config::types::SystemPromptMode;
919        use crate::prompts::harness_limits::upsert_harness_limits_section;
920        use crate::prompts::static_prompts::static_profile_prompt;
921        use crate::prompts::system::PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE;
922
923        let shell = ResolvedShellPromptProfile::UnixLike;
924        let execution_tools = [
925            TOOL_EXEC_COMMAND,
926            TOOL_WRITE_STDIN,
927            TOOL_APPLY_PATCH,
928            TOOL_CODE_SEARCH,
929            TOOL_TASK_TRACKER,
930            TOOL_START_PLANNING,
931            TOOL_REQUEST_USER_INPUT,
932        ]
933        .map(str::to_owned)
934        .to_vec();
935        let planning_tools = [
936            TOOL_EXEC_COMMAND,
937            TOOL_WRITE_STDIN,
938            TOOL_CODE_SEARCH,
939            TOOL_GREP_FILE,
940            TOOL_TASK_TRACKER,
941            TOOL_REQUEST_USER_INPUT,
942        ]
943        .map(str::to_owned)
944        .to_vec();
945        let mut minimal_planning = generate_tool_guidelines_with_capabilities(
946            &planning_tools,
947            None,
948            shell,
949            ToolGuidanceProfile::Minimal,
950            false,
951        );
952        append_minimal_planning_addendum(&mut minimal_planning, &ToolPresence::of(&planning_tools));
953        let variants = [
954            ("default", generate_tool_guidelines_for_profile(&execution_tools, None, shell, true), false),
955            (
956                "minimal",
957                generate_tool_guidelines_with_capabilities(
958                    &execution_tools,
959                    None,
960                    shell,
961                    ToolGuidanceProfile::Minimal,
962                    true,
963                ),
964                false,
965            ),
966            ("default planning", generate_runtime_tool_guidelines_for_profile(&planning_tools, true, shell), true),
967            ("minimal planning", minimal_planning, true),
968        ];
969        // Each marker names one universal rule that lives in Runtime Guidance
970        // or the shared contract and must not be restated by tool sections.
971        let markers = [
972            "never claim a check passed",
973            "Diagnose failures; change approach",
974            "Use returned `next_wait_args`",
975            "Treat empty searches as evidence",
976            "Check optional tools once",
977            "small non-overlapping ranges",
978            "accumulated output never exhausts tool access",
979            "additional_permissions",
980            "bypass safeguards",
981            "Delegate only sizeable",
982            "Across compaction",
983        ];
984
985        for mode in [
986            SystemPromptMode::Default,
987            SystemPromptMode::Minimal,
988            SystemPromptMode::Lightweight,
989            SystemPromptMode::Specialized,
990        ] {
991            for (variant, guidance, planning) in &variants {
992                let mut prompt = static_profile_prompt(mode).to_owned();
993                if *planning {
994                    prompt.push_str("\n\n");
995                    prompt.push_str(PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE);
996                }
997                prompt.push_str(guidance);
998                upsert_harness_limits_section(&mut prompt, 32, 600, 2);
999                for marker in markers {
1000                    assert_eq!(
1001                        prompt.matches(marker).count(),
1002                        1,
1003                        "{mode:?} with {variant} tool guidance should state {marker:?} exactly once"
1004                    );
1005                }
1006                let start_planning_mentions = prompt.matches("start_planning").count();
1007                assert_eq!(start_planning_mentions, usize::from(!*planning), "{mode:?} with {variant} tool guidance");
1008            }
1009        }
1010    }
1011
1012    #[test]
1013    fn documentation_profile_respects_context_tokens_and_known_cost() {
1014        assert_eq!(ToolGuidanceProfile::resolve(32_000, 100, 1000, None, None), ToolGuidanceProfile::Minimal);
1015        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 100, 1000, None, Some(0.0)), ToolGuidanceProfile::Default);
1016        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 1001, 1000, None, None), ToolGuidanceProfile::Minimal);
1017        assert_eq!(
1018            ToolGuidanceProfile::resolve(1_000_000, 1000, 2000, Some(0.00001), Some(0.001)),
1019            ToolGuidanceProfile::Minimal
1020        );
1021    }
1022
1023    #[test]
1024    fn minimal_and_default_tool_guidance_snapshots() {
1025        let tools = vec![TOOL_READ_FILE.to_owned()];
1026        let minimal = generate_tool_guidelines_with_capabilities(
1027            &tools,
1028            None,
1029            ResolvedShellPromptProfile::UnixLike,
1030            ToolGuidanceProfile::Minimal,
1031            false,
1032        );
1033        assert_eq!(
1034            minimal,
1035            "\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."
1036        );
1037        let default = generate_tool_guidelines_with_capabilities(
1038            &tools,
1039            None,
1040            ResolvedShellPromptProfile::UnixLike,
1041            ToolGuidanceProfile::Default,
1042            false,
1043        );
1044        assert_eq!(
1045            default,
1046            "\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`; prefer wider windows and reuse evidence over overlapping slices."
1047        );
1048    }
1049
1050    #[test]
1051    fn tool_presence_flags_record_decision_in_the_single_pass() {
1052        let with = ToolPresence::of(&["matrix".to_string(), "record_decision".to_string()]);
1053        assert!(with.matrix);
1054        assert!(with.record_decision);
1055        let without = ToolPresence::of(&["matrix".to_string()]);
1056        assert!(without.matrix);
1057        assert!(!without.record_decision);
1058    }
1059
1060    #[test]
1061    fn public_decision_guidance_is_optional_and_bounded() {
1062        for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1063            let enabled = generate_tool_guidelines_with_capabilities(
1064                &[TOOL_READ_FILE.into(), "record_decision".into()],
1065                None,
1066                ResolvedShellPromptProfile::UnixLike,
1067                profile,
1068                false,
1069            );
1070            let disabled = generate_tool_guidelines_with_capabilities(
1071                &[TOOL_READ_FILE.into()],
1072                None,
1073                ResolvedShellPromptProfile::UnixLike,
1074                profile,
1075                false,
1076            );
1077            assert!(enabled.contains(PUBLIC_DECISION_GUIDANCE));
1078            assert!(!disabled.contains("record_decision"));
1079            assert!(enabled.len().saturating_sub(disabled.len()) < 300);
1080            assert!(!enabled.contains("chain of thought"));
1081        }
1082    }
1083
1084    #[test]
1085    fn tool_guidance_uses_actual_parallel_capabilities_for_three_families() {
1086        use crate::config::constants::models;
1087        use crate::llm::provider::LLMProvider;
1088        use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
1089        let providers: [(Box<dyn LLMProvider>, &str); 3] = [
1090            (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
1091            (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
1092            (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
1093        ];
1094        for (provider, model) in providers {
1095            for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1096                let parallel = provider.supports_parallel_tool_config(model);
1097                let text = generate_tool_guidelines_with_capabilities(
1098                    &[TOOL_EXEC_COMMAND.to_owned()],
1099                    None,
1100                    ResolvedShellPromptProfile::UnixLike,
1101                    profile,
1102                    parallel,
1103                );
1104                assert_eq!(text.contains("tools in parallel"), parallel);
1105                let serial = generate_tool_guidelines_with_capabilities(
1106                    &[TOOL_EXEC_COMMAND.to_owned()],
1107                    None,
1108                    ResolvedShellPromptProfile::UnixLike,
1109                    profile,
1110                    false,
1111                );
1112                assert!(!serial.contains("tools in parallel"));
1113            }
1114        }
1115    }
1116
1117    #[test]
1118    fn test_read_only_capability_detection() {
1119        let tools = vec![TOOL_CODE_SEARCH.to_string()];
1120        let guidelines = guidelines_for(&tools, None);
1121        assert!(guidelines.contains("Capabilities: read-only"));
1122        assert!(guidelines.contains("do not modify files"));
1123    }
1124
1125    #[test]
1126    fn test_tool_preference_guidance() {
1127        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_CODE_SEARCH.to_string()];
1128        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1129        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1130        assert!(guidelines.contains("literal smart-case"));
1131        assert!(guidelines.contains("exact syntactic usages"));
1132        assert!(guidelines.contains("\"result_types\":[\"definition\"]"));
1133        assert!(guidelines.contains("Do not JSON-encode arrays or integers as strings"));
1134        assert!(guidelines.contains("omit unused filters"));
1135        assert!(guidelines.contains("path: \"\""));
1136        assert!(guidelines.contains("git diff -- <path>"));
1137        assert!(guidelines.contains("build tools"));
1138        assert!(guidelines.contains("test tools"));
1139        // Search steering: with both tools present the browse line must
1140        // prefer `code_search` over `rg` for code (session evidence showed
1141        // `rg`-via-exec crowding out `code_search` 654:15).
1142        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1143        // Latency steering: fast checks before full builds (tool-agnostic).
1144        assert!(guidelines.contains("Run fast checks before full builds"));
1145        // Completion-as-checkpoint guidance lives in the operating profiles;
1146        // the guidelines section no longer repeats it.
1147        assert!(!guidelines.contains("Completion is a checkpoint"));
1148    }
1149
1150    #[test]
1151    fn test_edit_workflow_guidance() {
1152        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1153        let guidelines = guidelines_for(&tools, None);
1154        assert!(guidelines.contains("Use `apply_patch`"));
1155        assert!(guidelines.contains("patches small"));
1156        // Completion-as-checkpoint guidance lives in the operating profiles;
1157        // the guidelines section no longer repeats it.
1158        assert!(!guidelines.contains("verification resolved"));
1159    }
1160
1161    #[test]
1162    fn test_vt_code_guidance_omits_task_tracker() {
1163        let tools = vec![
1164            TOOL_EXEC_COMMAND.to_string(),
1165            TOOL_WRITE_STDIN.to_string(),
1166            TOOL_APPLY_PATCH.to_string(),
1167        ];
1168        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1169
1170        assert!(guidelines.contains("exec_command.cmd"));
1171        for command in ["ls", "rg", "find", "cat", "sed", "awk"] {
1172            assert!(
1173                guidelines.contains(&format!("`{command}`")),
1174                "{command} should be shown as an exec_command.cmd example"
1175            );
1176        }
1177        assert!(guidelines.contains("`write_stdin`"));
1178        assert!(guidelines.contains("At most three live background processes"));
1179        assert!(guidelines.contains("`apply_patch`"));
1180        assert!(!guidelines.contains("task_tracker"));
1181        assert!(!guidelines.contains("list_files"));
1182        assert!(!guidelines.contains("read_file"));
1183    }
1184
1185    #[test]
1186    fn task_tracker_guidance_explains_action_aware_indices() {
1187        let guidelines = generate_runtime_tool_guidelines_for_profile(
1188            &[TOOL_TASK_TRACKER.to_string()],
1189            true,
1190            ResolvedShellPromptProfile::UnixLike,
1191        );
1192
1193        assert!(guidelines.contains(&format!("\n{PLANNING_TASK_TRACKER_INDEX_LINE}")));
1194        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("positive flat indices"));
1195        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("(index 0 is invalid while planning)"));
1196        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("full checklist replacement"));
1197        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"list"}"#));
1198        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"update","index":1,"status":"in_progress"}"#));
1199        assert!(
1200            PLANNING_TASK_TRACKER_INDEX_LINE.contains(r#"{"action":"update","items":["[x] Done","[ ] Remaining"]}"#)
1201        );
1202        assert!(PLANNING_TASK_TRACKER_COMPACT_LINE.contains("never conflicting fields"));
1203        // The planning sidecar rejects index 0, so no planning line may present
1204        // it as a valid checklist-completion index.
1205        for line in [PLANNING_TASK_TRACKER_INDEX_LINE, PLANNING_TASK_TRACKER_COMPACT_LINE] {
1206            assert!(line.contains("index 0 is invalid while planning"), "{line}");
1207            assert!(!line.contains("reserved"), "{line}");
1208            assert!(!line.contains("index: 0"), "{line}");
1209        }
1210    }
1211
1212    #[test]
1213    fn unix_like_guidance_makes_command_reuse_explicit() {
1214        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_WRITE_STDIN.to_string()];
1215        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1216
1217        assert!(guidelines.contains("one-shot `exec_command` calls"));
1218        assert!(guidelines.contains("`!!`, `!$`, `!ssh`, or `fc`"));
1219        assert!(guidelines.contains("write full command arguments explicitly"));
1220        assert!(guidelines.contains("conversation or tool results"));
1221        assert!(guidelines.contains("existing `session_id`"));
1222        assert!(guidelines.contains("background: true"));
1223        assert!(guidelines.contains("Bash `histverify`"));
1224        assert!(guidelines.contains("zsh `HIST_VERIFY`"));
1225        // Cross-turn resume (invariant #22): the live id arrives via a
1226        // turn-start `Exec session resume:` hint when a prior turn ended mid-run.
1227        assert!(guidelines.contains("`Exec session resume:`"));
1228        assert!(guidelines.contains("prior turn ended mid-run"));
1229        // No `code_search` in this profile: the search-preference clause
1230        // must not spend budget naming an unavailable tool.
1231        assert!(!guidelines.contains("Prefer `code_search` over `rg`"));
1232    }
1233
1234    #[test]
1235    fn write_stdin_guidance_advertises_cross_turn_resume_hint() {
1236        let tools = vec![TOOL_WRITE_STDIN.to_string()];
1237        let default_guidance =
1238            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1239        assert!(default_guidance.contains("`Exec session resume:`"));
1240        assert!(default_guidance.contains("prior turn ended mid-run"));
1241        assert!(default_guidance.contains("if missing, recover prior output"));
1242        assert!(default_guidance.contains("rerun only for fresh results"));
1243
1244        let minimal = generate_tool_guidelines_with_capabilities(
1245            &tools,
1246            None,
1247            ResolvedShellPromptProfile::UnixLike,
1248            ToolGuidanceProfile::Minimal,
1249            false,
1250        );
1251        assert!(minimal.contains("`Exec session resume:`"));
1252        assert!(minimal.contains("prior turn ended mid-run"));
1253        assert!(minimal.contains("if missing, recover prior output"));
1254    }
1255
1256    #[test]
1257    fn powershell_guidance_uses_native_command_examples() {
1258        let tools = vec![
1259            TOOL_EXEC_COMMAND.to_string(),
1260            TOOL_CODE_SEARCH.to_string(),
1261            TOOL_APPLY_PATCH.to_string(),
1262        ];
1263        let guidelines =
1264            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::PowerShell, true);
1265
1266        assert!(guidelines.contains("native PowerShell commands"));
1267        assert!(guidelines.contains("`Get-ChildItem`"));
1268        assert!(guidelines.contains("`Select-String`"));
1269        assert!(guidelines.contains("native PowerShell syntax"));
1270        assert!(guidelines.contains("Prefer `code_search` over `Select-String` for code"));
1271        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1272        assert!(guidelines.contains("literal smart-case"));
1273        assert!(guidelines.contains("omit unused filters"));
1274        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1275        assert!(!guidelines.contains("shell history expansion"));
1276        assert!(!guidelines.contains("histverify"));
1277        assert!(!guidelines.contains("HIST_VERIFY"));
1278    }
1279
1280    #[test]
1281    fn shell_profile_prompt_keeps_policy_and_syntax_separate() {
1282        let unix = render_shell_profile_guidance(ResolvedShellPromptProfile::UnixLike);
1283        assert!(unix.contains("Active shell profile: `unix_like`"));
1284        assert!(unix.contains("does not rewrite GNU flags for macOS BSD tools"));
1285        assert!(unix.contains("controls prompt examples and expected command syntax only"));
1286        assert!(unix.contains("does not translate GNU-to-BSD"));
1287
1288        let powershell = render_shell_profile_guidance(ResolvedShellPromptProfile::PowerShell);
1289        assert!(powershell.contains("Active shell profile: `powershell`"));
1290        assert!(powershell.contains("WSL"));
1291        assert!(powershell.contains("Unix-like workflows"));
1292        assert!(powershell.contains("PowerShell-to-Unix"));
1293    }
1294
1295    #[test]
1296    fn test_harness_browse_tool_guidance() {
1297        let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
1298        let guidelines = guidelines_for(&tools, None);
1299        assert!(guidelines.contains("available read-only repository tools"));
1300        assert!(guidelines.contains("bounded `read_file` ranges"));
1301        assert!(!guidelines.contains("list_files"));
1302        assert!(!guidelines.contains("offset"));
1303        assert!(!guidelines.contains("per_page"));
1304    }
1305
1306    #[test]
1307    fn test_canonical_browse_tool_guidance_prefers_public_tools() {
1308        let tools = vec![
1309            TOOL_CODE_SEARCH.to_string(),
1310            TOOL_LIST_FILES.to_string(),
1311            "read_file".to_string(),
1312        ];
1313        let guidelines = guidelines_for(&tools, None);
1314        assert!(guidelines.contains("available read-only repository tools"));
1315        assert!(guidelines.contains("code_search"));
1316        assert!(guidelines.contains("bounded `read_file` ranges"));
1317    }
1318
1319    #[test]
1320    fn test_capability_basic_guidance() {
1321        let tools = vec![];
1322        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::Basic));
1323        assert!(guidelines.contains("Capabilities: limited"));
1324        assert!(guidelines.contains("enable more capabilities"));
1325    }
1326
1327    #[test]
1328    fn test_capability_file_reading_guidance() {
1329        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1330        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::FileReading));
1331        assert!(guidelines.contains("Capabilities: read-only"));
1332        assert!(guidelines.contains("do not modify"));
1333    }
1334
1335    #[test]
1336    fn test_full_capabilities_no_special_guidance() {
1337        let tools = vec![
1338            TOOL_APPLY_PATCH.to_string(),
1339            TOOL_EXEC_COMMAND.to_string(),
1340            TOOL_CODE_SEARCH.to_string(),
1341        ];
1342        let guidelines = generate_tool_guidelines_for_profile(
1343            &tools,
1344            Some(CapabilityLevel::Editing),
1345            ResolvedShellPromptProfile::UnixLike,
1346            true,
1347        );
1348
1349        assert!(!guidelines.contains("Capabilities: limited"));
1350        assert!(!guidelines.contains("Capabilities: read-only"));
1351    }
1352
1353    #[test]
1354    fn test_empty_tools_shows_read_only_capabilities() {
1355        let tools = vec![];
1356        let guidelines = guidelines_for(&tools, None);
1357        assert!(guidelines.contains("Capabilities: read-only"));
1358    }
1359
1360    #[test]
1361    fn test_planning_workflow_guidance_keeps_verification_open() {
1362        let tools = vec![
1363            TOOL_EXEC_COMMAND.to_string(),
1364            TOOL_TASK_TRACKER.to_string(),
1365            TOOL_CODE_SEARCH.to_string(),
1366        ];
1367        let guidelines =
1368            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1369        assert!(guidelines.contains("Keep `task_tracker` updated"));
1370        assert!(guidelines.contains("blockers and verification open"));
1371    }
1372
1373    #[test]
1374    fn tool_presence_of_sets_each_flag_in_one_pass() {
1375        let tools = vec![
1376            TOOL_EXEC_COMMAND.to_string(),
1377            TOOL_WRITE_STDIN.to_string(),
1378            TOOL_CODE_SEARCH.to_string(),
1379            TOOL_READ_FILE.to_string(),
1380            TOOL_LIST_FILES.to_string(),
1381            TOOL_APPLY_PATCH.to_string(),
1382            TOOL_REQUEST_USER_INPUT.to_string(),
1383            TOOL_TASK_TRACKER.to_string(),
1384            TOOL_START_PLANNING.to_string(),
1385            "matrix".to_string(),
1386            "unknown_tool".to_string(),
1387        ];
1388        let presence = ToolPresence::of(&tools);
1389        assert!(presence.exec);
1390        assert!(presence.write_stdin);
1391        assert!(presence.code_search);
1392        assert!(presence.read_file);
1393        assert!(presence.list_files);
1394        assert!(presence.apply_patch);
1395        assert!(presence.request_user_input);
1396        assert!(presence.task_tracker);
1397        assert!(presence.start_planning);
1398        assert!(presence.matrix);
1399
1400        let empty = ToolPresence::of(&[]);
1401        assert!(!empty.exec);
1402        assert!(!empty.write_stdin);
1403        assert!(!empty.code_search);
1404        assert!(!empty.read_file);
1405        assert!(!empty.list_files);
1406        assert!(!empty.apply_patch);
1407        assert!(!empty.request_user_input);
1408        assert!(!empty.task_tracker);
1409        assert!(!empty.start_planning);
1410        assert!(!empty.matrix);
1411    }
1412
1413    #[test]
1414    fn test_capability_inference_precedence() {
1415        let tools = vec![TOOL_APPLY_PATCH.to_string(), TOOL_CODE_SEARCH.to_string()];
1416        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1417
1418        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_APPLY_PATCH.to_string()];
1419        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1420    }
1421
1422    #[test]
1423    fn test_capability_inference_variants() {
1424        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1425        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1426
1427        let tools = vec![TOOL_EXEC_COMMAND.to_string()];
1428        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
1429
1430        let tools = vec![TOOL_CODE_SEARCH.to_string()];
1431        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1432
1433        let tools = vec![TOOL_LIST_FILES.to_string()];
1434        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
1435
1436        let tools = vec!["read_file".to_string()];
1437        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
1438
1439        let tools = vec!["unknown_tool".to_string()];
1440        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
1441    }
1442
1443    #[test]
1444    fn test_guidelines_stay_compact() {
1445        let tools = vec![
1446            TOOL_EXEC_COMMAND.to_string(),
1447            TOOL_CODE_SEARCH.to_string(),
1448            "read_file".to_string(),
1449            TOOL_LIST_FILES.to_string(),
1450            "apply_patch".to_string(),
1451        ];
1452        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1453        assert!(guidelines.contains("Batch independent read-only calls"));
1454        assert!(guidelines.contains("code_search"));
1455        // Exec-capable profiles describe the fail-closed verifier pipeline
1456        // contract, including the terminal outcome and rejected shell forms.
1457        assert!(guidelines.contains("static read-only filtering pipelines use fail-closed `pipefail`"));
1458        assert!(guidelines.contains("Only terminal exit 0 clears verification"));
1459        assert!(guidelines.contains("Recheck after `--fix`"));
1460        assert!(guidelines.contains("dynamic syntax, mutating tails, `;`, and `||` do not qualify"));
1461        assert!(!guidelines.contains("elided"));
1462        assert!(guidelines.contains("max_output_tokens"));
1463        assert!(guidelines.contains("Build and Auto share tools and safety gates"));
1464        let approx_tokens = vtcode_commons::estimate_tokens(&guidelines);
1465        // The batching, bounded-diff, and verifier-discipline guardrails are
1466        // intentionally part of the compact shared prompt. Raised from 500 so
1467        // the verifier rule can state its reason (a visible exit status).
1468        // Raised to 580 for the Sonnet 5.5 low-effort real-check rule
1469        // (syntax-only/failed-to-start do not count; project package manager,
1470        // never sudo; state which check was skipped and why).
1471        // Raised to 640 for wider-window reuse and code_search unavailable
1472        // no-retry guardrails (session-20261015 overlapping reads + identical
1473        // retries).
1474        // Source inspection is separate from verification and retained in both densities.
1475        assert!(approx_tokens < 640, "got ~{approx_tokens} tokens");
1476    }
1477
1478    #[test]
1479    fn deferred_tools_section_caps_groups_and_truncates_descriptions() {
1480        use crate::llm::provider::ToolNamespace;
1481
1482        fn deferred_mcp_tool(server: &str, tool: &str, description: &str) -> ToolDefinition {
1483            let mut definition = ToolDefinition::function(
1484                format!("mcp__{server}__{tool}"),
1485                "deferred".to_string(),
1486                serde_json::json!({"type": "object"}),
1487            );
1488            definition.namespace = Some(ToolNamespace {
1489                name: server.to_string(),
1490                description: description.to_string(),
1491            });
1492            definition.defer_loading = Some(true);
1493            definition
1494        }
1495
1496        let mut tools = Vec::new();
1497        for index in 0..10 {
1498            tools.push(deferred_mcp_tool(&format!("server-{index:02}"), "search", "Tools provided by MCP server"));
1499        }
1500        // One group with an unbounded server-provided description.
1501        tools.push(deferred_mcp_tool("server-long", "search", &"d".repeat(500)));
1502
1503        let mut prompt = "Base prompt".to_string();
1504        append_deferred_tools_prompt_section(&mut prompt, &tools);
1505
1506        assert!(prompt.contains("[Deferred Tools]"));
1507        assert!(prompt.contains("server-00 (1 tools)"));
1508        assert!(prompt.contains("server-07 (1 tools)"));
1509        assert!(!prompt.contains("server-08 (1 tools)"), "groups past the cap must collapse into overflow");
1510        assert!(!prompt.contains("server-09 (1 tools)"));
1511        assert!(
1512            prompt.contains("(+3 more deferred groups available"),
1513            "10 named groups + 1 long group over the 8-group cap overflows by 3"
1514        );
1515        assert!(!prompt.contains(&"d".repeat(121)), "group descriptions must stay truncated");
1516        assert!(prompt.contains("Use `search_tools` to find a deferred capability"));
1517
1518        // Idempotent: re-appending replaces the section instead of duplicating it.
1519        append_deferred_tools_prompt_section(&mut prompt, &tools);
1520        assert_eq!(prompt.matches("[Deferred Tools]").count(), 1);
1521    }
1522
1523    #[test]
1524    fn deferred_tools_section_omits_empty_and_fully_loaded_groups() {
1525        let mut prompt = "Base prompt".to_string();
1526        append_deferred_tools_prompt_section(&mut prompt, &[]);
1527        assert!(!prompt.contains("[Deferred Tools]"));
1528
1529        let loaded = ToolDefinition::function(
1530            "exec_command".to_string(),
1531            "Shell".to_string(),
1532            serde_json::json!({"type": "object"}),
1533        );
1534        let mut prompt = "Base prompt".to_string();
1535        append_deferred_tools_prompt_section(&mut prompt, &[loaded]);
1536        assert!(!prompt.contains("[Deferred Tools]"));
1537    }
1538
1539    #[test]
1540    fn truncate_deferred_description_keeps_boundaries_and_char_safety() {
1541        // Asymmetric boundaries: exactly at the cap stays verbatim, one over
1542        // truncates with an ellipsis. Unicode stays on char boundaries.
1543        let short = "Tools provided by MCP server";
1544        assert_eq!(truncate_deferred_description(short), short);
1545
1546        let empty = "";
1547        assert_eq!(truncate_deferred_description(empty), "");
1548
1549        let exact: String = "a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS);
1550        assert_eq!(truncate_deferred_description(&exact), exact);
1551
1552        let one_over: String = "a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS + 1);
1553        let truncated = truncate_deferred_description(&one_over);
1554        assert!(truncated.ends_with("..."));
1555        assert!(!truncated.contains(&"a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS + 1)));
1556        assert_eq!(truncated.chars().count(), DEFERRED_TOOLS_MAX_DESC_CHARS + 3);
1557
1558        // Multi-byte chars at the cut point must not panic or split bytes.
1559        let unicode_exact = format!("{}🚀", "a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS - 1));
1560        assert_eq!(unicode_exact.chars().count(), DEFERRED_TOOLS_MAX_DESC_CHARS);
1561        assert_eq!(truncate_deferred_description(&unicode_exact), unicode_exact);
1562
1563        let unicode_over = format!("{}🚀b", "a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS - 1));
1564        let unicode_truncated = truncate_deferred_description(&unicode_over);
1565        assert!(unicode_truncated.ends_with("..."));
1566        assert!(unicode_truncated.contains("🚀"));
1567
1568        // Trailing blanks inside the window are trimmed before the ellipsis.
1569        let padded = format!("{}   {}", "a".repeat(DEFERRED_TOOLS_MAX_DESC_CHARS - 4), "b".repeat(10));
1570        let padded_truncated = truncate_deferred_description(&padded);
1571        assert!(padded_truncated.ends_with("..."));
1572        assert!(!padded_truncated.contains(" ..."));
1573    }
1574
1575    #[test]
1576    fn test_parallel_tool_call_guidance() {
1577        let tools = vec![
1578            TOOL_EXEC_COMMAND.to_string(),
1579            TOOL_CODE_SEARCH.to_string(),
1580            TOOL_APPLY_PATCH.to_string(),
1581        ];
1582        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1583        assert!(guidelines.contains("parallel"), "Should include parallel tool call guidance");
1584        assert!(guidelines.contains("inputs do not depend"), "Should mention independent inputs");
1585    }
1586
1587    #[test]
1588    fn test_read_only_batching_guidance_is_explicit() {
1589        let tools = vec![
1590            TOOL_CODE_SEARCH.to_string(),
1591            TOOL_READ_FILE.to_string(),
1592            TOOL_LIST_FILES.to_string(),
1593        ];
1594        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1595
1596        assert!(guidelines.contains("Batch independent read-only calls"));
1597        assert!(guidelines.contains("`read_file` ranges"));
1598        assert!(guidelines.contains("serialize mutations"));
1599        // No exec tools in this profile: the verifier truthful-status rule is
1600        // exec-conditional and must not spend budget here.
1601        assert!(!guidelines.contains("Only terminal exit 0 clears verification"));
1602    }
1603
1604    #[test]
1605    fn execution_agents_can_suggest_planning_for_demanding_tasks() {
1606        let tools = vec![TOOL_START_PLANNING.to_string(), TOOL_EXEC_COMMAND.to_string()];
1607        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1608
1609        assert_eq!(guidelines.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1610        let minimal = generate_tool_guidelines_with_capabilities(
1611            &tools,
1612            None,
1613            ResolvedShellPromptProfile::UnixLike,
1614            ToolGuidanceProfile::Minimal,
1615            false,
1616        );
1617        assert_eq!(minimal.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1618        // Without the tool, no profile mentions it.
1619        let without = generate_tool_guidelines_for_profile(
1620            &[TOOL_EXEC_COMMAND.to_string()],
1621            None,
1622            ResolvedShellPromptProfile::UnixLike,
1623            true,
1624        );
1625        assert!(!without.contains("start_planning"));
1626    }
1627
1628    #[test]
1629    fn markdown_validation_is_optional_in_planning_and_execution() {
1630        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_READ_FILE.to_string()];
1631        for planning in [false, true] {
1632            let guidance =
1633                generate_runtime_tool_guidelines_for_profile(&tools, planning, ResolvedShellPromptProfile::UnixLike);
1634            assert_eq!(guidance.matches(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE).count(), 1);
1635            assert!(guidance.contains("report skipped"));
1636            assert!(guidance.contains("without installing tools"));
1637            assert!(guidance.contains("Lint errors remain failures"));
1638        }
1639    }
1640
1641    #[test]
1642    fn planning_workflow_runtime_guidance_keeps_exec_read_only() {
1643        let tools = vec![
1644            TOOL_APPLY_PATCH.to_string(),
1645            TOOL_EXEC_COMMAND.to_string(),
1646            TOOL_CODE_SEARCH.to_string(),
1647        ];
1648        let guidelines =
1649            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1650
1651        assert!(guidelines.contains("Planning workflow active"));
1652        assert!(guidelines.contains("`exec_command` only for read-only verification"));
1653        assert!(!guidelines.contains("<proposed_plan>"));
1654        assert!(!guidelines.contains("Every implementation step"));
1655        assert!(guidelines.contains("omit unused filters"));
1656        assert!(!guidelines.contains("Inspect before edit"));
1657    }
1658
1659    #[test]
1660    fn minimal_planning_addendum_prefers_read_tools_over_shell_reads() {
1661        // Asymmetric pair over read-tool presence: with read tools the
1662        // inspection line names them and prefers them over shell reads;
1663        // without, only the generic small-inspection rule remains. Both
1664        // share the same git-history tail.
1665        let tail = "start git history with `git log --oneline`";
1666        let with_readers = ToolPresence::of(&[TOOL_READ_FILE.to_string(), TOOL_CODE_SEARCH.to_string()]);
1667        let mut guidance = String::new();
1668        append_minimal_planning_addendum(&mut guidance, &with_readers);
1669        assert!(guidance.contains("prefer `read_file`/`code_search` over `exec_command` shell reads"));
1670        assert!(guidance.contains(tail));
1671
1672        let without_readers = ToolPresence::of(&[TOOL_EXEC_COMMAND.to_string()]);
1673        let mut bare = String::new();
1674        append_minimal_planning_addendum(&mut bare, &without_readers);
1675        assert!(!bare.contains("prefer `"));
1676        assert!(!bare.contains("over `exec_command` shell reads"));
1677        assert!(bare.contains(tail));
1678    }
1679
1680    #[test]
1681    fn runtime_tool_guidance_uses_explicit_powershell_profile() {
1682        let tools = vec![
1683            TOOL_APPLY_PATCH.to_string(),
1684            TOOL_EXEC_COMMAND.to_string(),
1685            TOOL_CODE_SEARCH.to_string(),
1686        ];
1687        let guidelines =
1688            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::PowerShell);
1689
1690        assert!(guidelines.contains("native PowerShell commands"));
1691        assert!(guidelines.contains("`Get-ChildItem`"));
1692        assert!(guidelines.contains("`Select-String`"));
1693        assert!(guidelines.contains("native PowerShell syntax"));
1694        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1695    }
1696
1697    #[test]
1698    fn runtime_tool_guidance_uses_explicit_unix_like_profile() {
1699        let tools = vec![
1700            TOOL_APPLY_PATCH.to_string(),
1701            TOOL_EXEC_COMMAND.to_string(),
1702            TOOL_CODE_SEARCH.to_string(),
1703        ];
1704        let guidelines =
1705            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::UnixLike);
1706
1707        assert!(guidelines.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1708        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1709        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1710        assert!(guidelines.contains("literal smart-case"));
1711        assert!(guidelines.contains("shell-only tasks"));
1712        assert!(!guidelines.contains("native PowerShell commands"));
1713        assert!(!guidelines.contains("`Get-ChildItem`"));
1714    }
1715
1716    #[test]
1717    fn shell_browse_guidance_lists_rg_only_without_code_search() {
1718        // Asymmetric pair over `has_search`: without the dedicated search
1719        // tool the browse line must name `rg`; with it, `rg` drops out in
1720        // favor of the `code_search` preference suffix.
1721        let without_search = shell_browse_guidance(ResolvedShellPromptProfile::UnixLike, false);
1722        assert!(without_search.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1723        assert!(!without_search.contains("Prefer `code_search`"));
1724
1725        let with_search = shell_browse_guidance(ResolvedShellPromptProfile::UnixLike, true);
1726        assert!(with_search.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1727        assert!(!with_search.contains("`ls`, `rg`, `find`"));
1728        assert!(with_search.contains("Prefer `code_search` over `rg`/`grep` for code"));
1729    }
1730
1731    #[test]
1732    fn runtime_tool_prompt_sections_use_explicit_profile_for_active_tools() {
1733        let mut powershell_prompt = "Base prompt".to_string();
1734        let mut unix_prompt = "Base prompt".to_string();
1735        let snapshot = SessionToolCatalogSnapshot::new(
1736            7,
1737            9,
1738            false,
1739            false,
1740            Some(std::sync::Arc::new(vec![
1741                ToolDefinition::function(
1742                    TOOL_EXEC_COMMAND.to_string(),
1743                    "Shell".to_string(),
1744                    serde_json::json!({"type": "object"}),
1745                ),
1746                ToolDefinition::function(
1747                    TOOL_CODE_SEARCH.to_string(),
1748                    "Bounded source search".to_string(),
1749                    serde_json::json!({"type": "object"}),
1750                ),
1751            ])),
1752            false,
1753        );
1754
1755        append_runtime_tool_prompt_sections_for_profile(
1756            &mut powershell_prompt,
1757            &snapshot,
1758            false,
1759            ResolvedShellPromptProfile::PowerShell,
1760        );
1761        append_runtime_tool_prompt_sections_for_profile(
1762            &mut unix_prompt,
1763            &snapshot,
1764            false,
1765            ResolvedShellPromptProfile::UnixLike,
1766        );
1767
1768        assert!(powershell_prompt.contains("## Active Tools"));
1769        assert!(powershell_prompt.contains("`Get-ChildItem`"));
1770        assert!(powershell_prompt.contains("`Select-String`"));
1771        assert!(!powershell_prompt.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1772
1773        assert!(unix_prompt.contains("## Active Tools"));
1774        assert!(unix_prompt.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1775        assert!(unix_prompt.contains("Prefer `code_search` over `rg`/`grep` for code"));
1776        assert!(unix_prompt.contains("Advanced `code_search` takes `query`"));
1777        assert!(unix_prompt.contains("literal smart-case"));
1778        assert!(!unix_prompt.contains("`Get-ChildItem`"));
1779    }
1780
1781    #[test]
1782    fn runtime_tool_prompt_sections_include_catalog_metadata() {
1783        let mut prompt = "Base prompt".to_string();
1784        let snapshot = SessionToolCatalogSnapshot::new(
1785            7,
1786            9,
1787            true,
1788            false,
1789            Some(std::sync::Arc::new(vec![
1790                ToolDefinition::function(
1791                    TOOL_EXEC_COMMAND.to_string(),
1792                    "Search".to_string(),
1793                    serde_json::json!({"type": "object"}),
1794                ),
1795                ToolDefinition::function(
1796                    TOOL_APPLY_PATCH.to_string(),
1797                    "File".to_string(),
1798                    serde_json::json!({"type": "object"}),
1799                ),
1800            ])),
1801            false,
1802        );
1803
1804        append_runtime_tool_prompt_sections_for_profile(
1805            &mut prompt,
1806            &snapshot,
1807            true,
1808            ShellPromptProfile::Auto.resolve_for_current_platform(),
1809        );
1810
1811        assert!(prompt.contains("## Active Tools"));
1812        assert!(prompt.contains("[Runtime Tool Catalog]"));
1813        assert!(prompt.contains("catalog_tools: 2"));
1814        assert!(prompt.contains("currently_available_tools: exec_command, apply_patch"));
1815        assert!(prompt.contains("request_user_input_enabled: false"));
1816    }
1817
1818    #[test]
1819    fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
1820        let mut prompt = "Base prompt".to_string();
1821        let first = SessionToolCatalogSnapshot::new(
1822            1,
1823            2,
1824            false,
1825            false,
1826            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1827                TOOL_EXEC_COMMAND.to_string(),
1828                "Search".to_string(),
1829                serde_json::json!({"type": "object"}),
1830            )])),
1831            false,
1832        );
1833        let second = SessionToolCatalogSnapshot::new(
1834            7,
1835            9,
1836            true,
1837            true,
1838            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1839                TOOL_APPLY_PATCH.to_string(),
1840                "File".to_string(),
1841                serde_json::json!({"type": "object"}),
1842            )])),
1843            false,
1844        );
1845
1846        let shell = ShellPromptProfile::Auto.resolve_for_current_platform();
1847        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &first, true, shell);
1848        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &second, true, shell);
1849
1850        assert_eq!(prompt.matches("## Active Tools").count(), 1);
1851        assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
1852        assert!(prompt.contains("version: 7"));
1853        assert!(!prompt.contains("version: 1"));
1854        assert!(prompt.contains("request_user_input_enabled: true"));
1855        assert!(!prompt.contains("request_user_input_enabled: false"));
1856    }
1857}