Skip to main content

vtcode_core/prompts/system/
compose.rs

1//! System-instruction composition, budget trimming, and identity substitution.
2
3use std::path::Path;
4
5use crate::config::constants::prompt_budget as prompt_budget_constants;
6use crate::config::types::ShellPromptProfile;
7use crate::llm::providers::gemini::wire::Content;
8use crate::prompts::context::PromptContext;
9use crate::prompts::guidelines::{generate_tool_guidelines_for_profile, render_shell_profile_guidance};
10use crate::prompts::output_styles::OutputStyleApplier;
11use crate::prompts::render::render_environment_addenda;
12use crate::prompts::resources::{apply_system_prompt_layers, resolve_system_prompt_layers};
13use crate::prompts::static_prompts::static_profile_prompt;
14use crate::prompts::system_prompt_cache::PROMPT_CACHE;
15use crate::skills::render::render_prompt_skills_section;
16use tracing::warn;
17
18use super::cache::{cache_key_for_identity, prompt_context_digest};
19use super::constants::{PROMPT_IDENTITY_NAME, PROMPT_INTRO, PROMPT_TITLE, STRUCTURED_REASONING_INSTRUCTIONS};
20use super::tokens::estimate_token_count;
21use super::types::{PromptSection, SectionKind, SystemPromptConfig, SystemPromptReport};
22
23use crate::prompts::static_prompts::agent_identity_label;
24
25/// Compose the base system instruction plus compact tool/skill/environment addenda.
26pub async fn compose_system_instruction_text(
27    project_root: &Path,
28    vtcode_config: Option<&crate::config::VTCodeConfig>,
29    prompt_context: Option<&PromptContext>,
30) -> String {
31    compose_system_instruction_with_report(project_root, vtcode_config, prompt_context)
32        .await
33        .0
34}
35
36/// Compose the system instruction and return the token-budget report
37/// alongside it. See [`SystemPromptReport`] and `SectionKind::trim_priority`
38/// for the budget/trim behavior driven by `agent.max_system_prompt_tokens`,
39/// `agent.system_prompt_budget_warning`, and `agent.trim_system_prompt`.
40pub async fn compose_system_instruction_with_report(
41    project_root: &Path,
42    vtcode_config: Option<&crate::config::VTCodeConfig>,
43    prompt_context: Option<&PromptContext>,
44) -> (String, SystemPromptReport) {
45    let (prompt, report, _) =
46        compose_system_instruction_with_identity(project_root, vtcode_config, prompt_context).await;
47    (prompt, report)
48}
49
50/// Compose a prompt and retain the stable instruction digest used by local and
51/// provider-facing prompt caches.
52async fn compose_system_instruction_with_identity(
53    project_root: &Path,
54    vtcode_config: Option<&crate::config::VTCodeConfig>,
55    prompt_context: Option<&PromptContext>,
56) -> (String, SystemPromptReport, u64) {
57    let sections = build_prompt_sections(project_root, vtcode_config, prompt_context).await;
58    let instruction_digest = stable_prompt_sections_digest(&sections);
59    let (max_tokens, warn_enabled, trim_enabled) = system_prompt_budget_settings(vtcode_config);
60    let (prompt, report) = apply_token_budget(sections, max_tokens, warn_enabled, trim_enabled);
61    (prompt, report, instruction_digest)
62}
63
64/// Measure the system prompt size without applying budget trimming or warnings.
65///
66/// This is used at startup to warn about potential token budget overruns
67/// before the first request is made. Unlike [`compose_system_instruction_with_report`],
68/// this function does not apply `agent.trim_system_prompt` and does not emit
69/// budget-exceeded warnings.
70pub async fn measure_system_prompt_size(
71    project_root: &Path,
72    vtcode_config: &crate::config::VTCodeConfig,
73) -> SystemPromptReport {
74    let sections = build_prompt_sections(project_root, Some(vtcode_config), None).await;
75    let text = join_prompt_sections(&sections);
76    let token_estimate = estimate_token_count(&text);
77    SystemPromptReport {
78        token_estimate,
79        over_budget: token_estimate > vtcode_config.agent.max_system_prompt_tokens,
80        trimmed_sections: Vec::new(),
81    }
82}
83
84/// Resolve the effective `(max_system_prompt_tokens, budget_warning_enabled,
85/// trim_enabled)` settings, falling back to the `AgentConfig` defaults when
86/// no config is available.
87fn system_prompt_budget_settings(vtcode_config: Option<&crate::config::VTCodeConfig>) -> (u64, bool, bool) {
88    vtcode_config.map_or((prompt_budget_constants::DEFAULT_MAX_SYSTEM_PROMPT_TOKENS, true, true), |cfg| {
89        (
90            cfg.agent.max_system_prompt_tokens,
91            cfg.agent.system_prompt_budget_warning,
92            cfg.agent.trim_system_prompt,
93        )
94    })
95}
96
97/// Build the ordered prompt sections. Each section's text is stored exactly
98/// as the legacy single-string builder would have appended it, so
99/// [`join_prompt_sections`] reproduces byte-identical output when nothing is
100/// trimmed.
101pub(super) async fn build_prompt_sections(
102    project_root: &Path,
103    vtcode_config: Option<&crate::config::VTCodeConfig>,
104    prompt_context: Option<&PromptContext>,
105) -> Vec<PromptSection> {
106    let prompt_mode = vtcode_config.map(|c| c.agent.system_prompt_mode).unwrap_or_default();
107    let static_base_prompt = static_profile_prompt(prompt_mode);
108    let resolved_layers = resolve_system_prompt_layers(project_root).await;
109    let mut base_prompt = apply_system_prompt_layers(static_base_prompt, &resolved_layers);
110    crate::prompts::runtime_guidance::ensure_runtime_guidance(&mut base_prompt);
111
112    tracing::trace!(
113        mode = ?prompt_mode,
114        base_tokens = estimate_token_count(&base_prompt),
115        "Selected system prompt mode"
116    );
117
118    // Apply agent identity based on the default primary agent configuration.
119    // This combines "VT Code" with the active agent mode so the LLM knows its role.
120    if let Some(cfg) = vtcode_config {
121        let agent_label = agent_identity_label(&cfg.default_primary_agent);
122        base_prompt = apply_agent_identity(&base_prompt, &agent_label);
123        apply_coordinator_role_guidance(&mut base_prompt, cfg.default_primary_agent == "coordinator");
124    }
125
126    let mut sections = vec![PromptSection { kind: SectionKind::BaseContract, text: base_prompt }];
127
128    if should_include_structured_reasoning(vtcode_config) {
129        sections.push(PromptSection {
130            kind: SectionKind::StructuredReasoning,
131            text: STRUCTURED_REASONING_INSTRUCTIONS.to_string(),
132        });
133    }
134
135    let shell_profile = vtcode_config
136        .map(|cfg| cfg.agent.shell_prompt_profile)
137        .unwrap_or(ShellPromptProfile::Auto)
138        .resolve_for_current_platform();
139    sections.push(PromptSection {
140        kind: SectionKind::ShellProfile,
141        text: render_shell_profile_guidance(shell_profile),
142    });
143
144    if let Some(ctx) = prompt_context {
145        // Prompt-caching discipline: static content first, dynamic last.
146        // `Skills` is session-stable routing metadata while `ToolGuidelines`
147        // derives from the live tool catalog (planning toggles, MCP refreshes),
148        // so skills must precede tool guidelines. Otherwise every catalog
149        // change would invalidate the cached skills section that follows it.
150        if let Some(skills_section) = render_prompt_skills_section(&ctx.available_skill_metadata) {
151            sections.push(PromptSection { kind: SectionKind::Skills, text: skills_section });
152        }
153        // Static prompts ship the parallel-call hint unconditionally; only the
154        // runtime per-turn path re-resolves it against the provider.
155        let guidelines =
156            generate_tool_guidelines_for_profile(&ctx.available_tools, ctx.capability_level, shell_profile, true);
157        if !guidelines.is_empty() {
158            sections.push(PromptSection {
159                kind: SectionKind::ToolGuidelines,
160                text: guidelines.trim_start_matches('\n').to_string(),
161            });
162        }
163    }
164
165    if let Some(environment_section) = render_environment_addenda(vtcode_config, prompt_context) {
166        sections.push(PromptSection {
167            kind: SectionKind::EnvironmentAddenda,
168            text: environment_section,
169        });
170    }
171
172    sections
173}
174
175/// Join ordered prompt sections exactly as the legacy single-string builder
176/// did: the first section verbatim, then each subsequent section separated
177/// by a blank line.
178pub(super) fn join_prompt_sections(sections: &[PromptSection]) -> String {
179    let capacity = sections.iter().map(|section| section.text.len() + 2).sum();
180    let mut joined = String::with_capacity(capacity);
181    for (index, section) in sections.iter().enumerate() {
182        if index > 0 {
183            joined.push_str("\n\n");
184        }
185        joined.push_str(&section.text);
186    }
187    joined
188}
189
190/// Enforce the configured system-prompt token budget against the composed
191/// sections.
192///
193/// When under budget, sections are joined and returned unchanged. When over
194/// budget and `trim_enabled` is false, the full untrimmed text is still used
195/// but a warning is logged (gated on `warn_enabled`). When over budget and
196/// `trim_enabled` is true, whole sections are dropped in
197/// [`SectionKind::trim_priority`] order (lowest first), re-measuring after
198/// each drop, until the prompt fits or only untrimmable sections remain.
199pub(super) fn apply_token_budget(
200    mut sections: Vec<PromptSection>,
201    max_tokens: u64,
202    warn_enabled: bool,
203    trim_enabled: bool,
204) -> (String, SystemPromptReport) {
205    let mut text = join_prompt_sections(&sections);
206    let mut token_estimate = estimate_token_count(&text);
207    let mut trimmed_sections: Vec<&'static str> = Vec::new();
208
209    if token_estimate > max_tokens {
210        if trim_enabled {
211            while token_estimate > max_tokens {
212                let drop_index = sections
213                    .iter()
214                    .enumerate()
215                    .filter_map(|(index, section)| section.kind.trim_priority().map(|priority| (priority, index)))
216                    .min_by_key(|(priority, _)| *priority)
217                    .map(|(_, index)| index);
218                let Some(drop_index) = drop_index else {
219                    break;
220                };
221                let dropped = sections.remove(drop_index);
222                trimmed_sections.push(dropped.kind.name());
223                text = join_prompt_sections(&sections);
224                token_estimate = estimate_token_count(&text);
225            }
226
227            if !trimmed_sections.is_empty() {
228                tracing::warn!(
229                    token_estimate,
230                    max_system_prompt_tokens = max_tokens,
231                    dropped_sections = ?trimmed_sections,
232                    "Trimmed system prompt sections to satisfy token budget"
233                );
234            }
235        } else if warn_enabled {
236            tracing::warn!(
237                token_estimate,
238                max_system_prompt_tokens = max_tokens,
239                "System prompt exceeds configured token budget"
240            );
241        }
242    }
243
244    let report = SystemPromptReport {
245        token_estimate,
246        over_budget: token_estimate > max_tokens,
247        trimmed_sections,
248    };
249    (text, report)
250}
251
252/// Hash only the explicitly stable prompt sections.
253pub(super) fn stable_prompt_sections_digest(sections: &[PromptSection]) -> u64 {
254    let stable_sections = sections
255        .iter()
256        .filter(|section| section.kind.is_cache_stable())
257        .map(|section| (section.kind.name(), section.text.as_str()))
258        .collect::<Vec<_>>();
259    crate::core::agent::hash_utils::hash_value(&stable_sections)
260}
261
262/// Align cached base guidance with the selected role, including switches away from the coordinator.
263pub fn apply_coordinator_role_guidance(prompt: &mut String, coordinator_active: bool) {
264    use crate::prompts::sections::{SectionBoundaryMode, find_prompt_section_bounds};
265
266    const ORDINARY_DELEGATION: &str = "- Delegate only sizeable, independent work to subagents; keep small tasks and verification in the main thread.";
267    const COORDINATOR_DELEGATION: &str = "- As coordinator, delegate execution and verification to scheduler-owned matrix workers; keep decisions in the main thread.";
268
269    if !coordinator_active && !prompt.contains("## Coordinator Role") && !prompt.contains(COORDINATOR_DELEGATION) {
270        return;
271    }
272
273    let coordinator_role =
274        format!("## Coordinator Role\n{}\n", vtcode_config::subagents::builtin_primary_coordinator_agent().prompt);
275    if coordinator_active
276        && prompt.matches("## Coordinator Role").count() == 1
277        && prompt.contains(COORDINATOR_DELEGATION)
278        && let Some((start, end)) =
279            find_prompt_section_bounds(prompt, "## Coordinator Role", SectionBoundaryMode::BracketOrMarkdown)
280        && prompt
281            .get(start..end)
282            .is_some_and(|section| section.trim() == coordinator_role.trim())
283    {
284        return;
285    }
286
287    while let Some((start, end)) =
288        find_prompt_section_bounds(prompt, "## Coordinator Role", SectionBoundaryMode::BracketOrMarkdown)
289    {
290        prompt.replace_range(start..end, "");
291    }
292    let (previous, current) = if coordinator_active {
293        (ORDINARY_DELEGATION, COORDINATOR_DELEGATION)
294    } else {
295        (COORDINATOR_DELEGATION, ORDINARY_DELEGATION)
296    };
297    *prompt = prompt.replace(previous, current);
298    if coordinator_active {
299        prompt.push_str("\n\n");
300        prompt.push_str(&coordinator_role);
301    }
302}
303
304/// Apply agent identity to the system prompt by replacing the title and intro lines.
305/// This combines the "VT Code" identity with the active agent mode so the LLM
306/// knows its role (e.g., "VT Code (Build mode)" or "VT Code (Auto mode)").
307pub(super) fn apply_agent_identity(prompt: &str, agent_label: &str) -> String {
308    let mut result = prompt.to_string();
309    let old_title = PROMPT_TITLE;
310    let labeled_title = old_title.replacen(PROMPT_IDENTITY_NAME, agent_label, 1);
311    let old_intro = PROMPT_INTRO;
312
313    let title_found = if let Some(pos) = result.find(old_title) {
314        result.replace_range(pos..pos + old_title.len(), &labeled_title);
315        true
316    } else {
317        warn!("Could not find prompt title '{}' to apply agent identity", old_title);
318        false
319    };
320
321    let intro_found = if let Some(pos) = result.find(old_intro) {
322        let labeled_intro = old_intro.replacen(PROMPT_IDENTITY_NAME, agent_label, 1);
323        result.replace_range(pos..pos + old_intro.len(), &labeled_intro);
324        true
325    } else {
326        warn!("Could not find prompt intro '{}' to apply agent identity", old_intro);
327        false
328    };
329
330    if !title_found || !intro_found {
331        warn!(
332            agent_label = %agent_label,
333            title_replaced = title_found,
334            intro_replaced = intro_found,
335            "agent identity partially applied"
336        );
337    }
338
339    result
340}
341
342/// Structured reasoning tags are opt-in (`agent.include_structured_reasoning_tags`)
343/// in every prompt mode; without a config there is nothing to opt in.
344pub(super) fn should_include_structured_reasoning(vtcode_config: Option<&crate::config::VTCodeConfig>) -> bool {
345    vtcode_config.is_some_and(|cfg| cfg.agent.should_include_structured_reasoning_tags())
346}
347
348/// Generate the stable base system instruction with configuration-aware sections.
349///
350/// Note: This function maintains backward compatibility by not accepting prompt_context.
351/// For enhanced prompts with dynamic guidelines, call `compose_system_instruction_text` directly.
352pub async fn generate_system_instruction_with_config(
353    config: &SystemPromptConfig,
354    project_root: &Path,
355    vtcode_config: Option<&crate::config::VTCodeConfig>,
356) -> Content {
357    let (content, _report) =
358        generate_system_instruction_with_config_and_report(config, project_root, vtcode_config).await;
359    content
360}
361
362/// Same as [`generate_system_instruction_with_config`] but also returns the
363/// [`SystemPromptReport`] for the composed prompt, whether served from cache
364/// or freshly built.
365pub async fn generate_system_instruction_with_config_and_report(
366    _config: &SystemPromptConfig,
367    project_root: &Path,
368    vtcode_config: Option<&crate::config::VTCodeConfig>,
369) -> (Content, SystemPromptReport) {
370    generate_system_instruction_with_context_and_report(_config, project_root, vtcode_config, None).await
371}
372
373/// Generate a system instruction using a context-aware cache identity.
374///
375/// Context-free and context-aware prompts intentionally use different cache
376/// keys. This prevents a prompt assembled with workspace tools or skill
377/// metadata from being returned to a caller that requested the static prompt.
378pub async fn generate_system_instruction_with_context_and_report(
379    _config: &SystemPromptConfig,
380    project_root: &Path,
381    vtcode_config: Option<&crate::config::VTCodeConfig>,
382    prompt_context: Option<&PromptContext>,
383) -> (Content, SystemPromptReport) {
384    let (built_instruction, built_report, instruction_digest) =
385        compose_system_instruction_with_identity(project_root, vtcode_config, prompt_context).await;
386    let cache_key = cache_key_for_identity(
387        project_root,
388        vtcode_config,
389        instruction_digest,
390        prompt_context_digest(prompt_context),
391        0,
392    );
393    let (instruction, report) = match PROMPT_CACHE.get(&cache_key) {
394        Some(cached) => cached,
395        None => {
396            let built = (built_instruction, built_report);
397            PROMPT_CACHE.insert(cache_key, built.clone());
398            built
399        }
400    };
401
402    // Apply output style if configured
403    let styled_instruction = apply_output_style(instruction, vtcode_config, project_root).await;
404    (Content::system_text(styled_instruction), report)
405}
406
407/// Apply output style to a generated system instruction
408pub async fn apply_output_style(
409    instruction: String,
410    vtcode_config: Option<&crate::config::VTCodeConfig>,
411    project_root: &Path,
412) -> String {
413    if let Some(config) = vtcode_config {
414        let output_style_applier = OutputStyleApplier::new();
415        if let Err(e) = output_style_applier.load_styles_from_config(config, project_root).await {
416            tracing::warn!("Failed to load output styles: {}", e);
417            instruction // Return original if loading fails
418        } else {
419            output_style_applier
420                .apply_style(&config.output_style.active_style, &instruction, config)
421                .await
422        }
423    } else {
424        instruction // Return original if no config
425    }
426}