Skip to main content

distri_types/
prompt.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use handlebars::Handlebars;
6use handlebars::handlebars_helper;
7use serde::{Deserialize, Serialize};
8use tokio::sync::RwLock;
9
10use crate::{AgentError, ContextBudget, Message, Part};
11
12/// A registry for prompt templates that can be used across the system.
13///
14/// Supports two-zone prompt construction with section-level caching:
15/// - **Static zone**: Cached across turns/sessions, hashable for API-side caching
16/// - **Dynamic zone**: Recomputed each turn (env, scratchpad, tools, skills)
17///
18/// Section-level cache: individual rendered sections can be memoized per-session
19/// to avoid re-rendering unchanged content.
20#[derive(Debug, Clone)]
21pub struct PromptRegistry {
22    templates: Arc<RwLock<HashMap<String, PromptTemplate>>>,
23    partials: Arc<RwLock<HashMap<String, String>>>,
24    /// Section-level render cache: maps section_key → (rendered_content, token_count)
25    section_cache: Arc<RwLock<HashMap<String, (String, usize)>>>,
26    /// Cached hash of the static prefix for prompt cache optimization
27    static_prefix_hash: Arc<RwLock<Option<String>>>,
28}
29
30/// A prompt template with metadata.
31#[derive(Debug, Clone)]
32pub struct PromptTemplate {
33    pub name: String,
34    pub content: String,
35    pub description: Option<String>,
36    pub version: Option<String>,
37}
38
39#[derive(Debug, Clone, Default, Serialize)]
40pub struct TemplateData<'a> {
41    pub description: String,
42    pub instructions: String,
43    pub available_tools: String,
44    pub task: String,
45    pub scratchpad: String,
46    pub dynamic_sections: Vec<PromptSection>,
47    #[serde(flatten)]
48    pub dynamic_values: std::collections::HashMap<String, serde_json::Value>,
49    /// Session values fetched from the session store - available in templates as {{session.key}}
50    pub session_values: std::collections::HashMap<String, serde_json::Value>,
51    pub reasoning_depth: &'a str,
52    pub execution_mode: &'a str,
53    pub tool_format: &'a str,
54    pub show_examples: bool,
55    pub max_steps: usize,
56    pub current_steps: usize,
57    pub remaining_steps: usize,
58    pub todos: Option<String>,
59    pub json_tools: bool,
60    /// Formatted list of available skills the agent can load on demand
61    #[serde(default)]
62    pub available_skills: Option<String>,
63    /// Concatenated tool prompts/instructions (all tools).
64    /// Available in templates as `{{{tool_prompts}}}`.
65    #[serde(default)]
66    pub tool_prompts: String,
67    /// Per-tool prompt list for fine-grained control in templates.
68    /// Use `{{#each tool_prompt_list}}` to iterate, each has `.name` and `.prompt`.
69    #[serde(default)]
70    pub tool_prompt_list: Vec<ToolPromptEntry>,
71    /// Formatted list of deferred tools (name + description only, no schemas).
72    /// When present, rendered in the dynamic_suffix partial to tell the model
73    /// it can use `tool_search` to fetch full schemas on demand.
74    #[serde(default)]
75    pub deferred_tools_listing: Option<String>,
76    /// Channel/surface this conversation is happening on
77    /// ("telegram", "whatsapp", "discord", "slack", "web"). Read by the
78    /// `channel_formatting` partial in `dynamic_suffix.hbs` to give the
79    /// agent surface-specific output guidance. None when the run isn't tied
80    /// to a chat surface (web direct, CLI, A2A).
81    #[serde(default)]
82    pub channel_kind: Option<String>,
83    /// Runtime mode this agent is executing under. One of `"cli"`,
84    /// `"cloud"`, `"browser"`, or `""` if the formatter wasn't given a
85    /// runtime to advertise. Use to conditionally render runtime-specific
86    /// guidance — filesystem-access copy in CLI, sandbox-dispatch hints in
87    /// Cloud, browser-tool callouts in Browser. Templates can branch with
88    /// `{{#if (eq runtime_mode "cli")}}…{{/if}}`. Built from
89    /// `ExecutorContext.runtime_mode` in
90    /// `agent::strategy::planning::formatter::build_messages`.
91    #[serde(default)]
92    pub runtime_mode: &'a str,
93}
94
95/// A single tool's prompt entry for template iteration.
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct ToolPromptEntry {
98    pub name: String,
99    pub prompt: String,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct PromptSection {
104    pub key: String,
105    pub content: String,
106}
107
108impl PromptRegistry {
109    pub fn new() -> Self {
110        Self {
111            templates: Arc::new(RwLock::new(HashMap::new())),
112            partials: Arc::new(RwLock::new(HashMap::new())),
113            section_cache: Arc::new(RwLock::new(HashMap::new())),
114            static_prefix_hash: Arc::new(RwLock::new(None)),
115        }
116    }
117
118    /// Create a registry preloaded with the built-in templates/partials.
119    pub async fn with_defaults() -> Result<Self, AgentError> {
120        let registry = Self::new();
121        registry.register_static_templates().await?;
122        registry.register_static_partials().await?;
123        Ok(registry)
124    }
125
126    async fn register_static_templates(&self) -> Result<(), AgentError> {
127        let templates = vec![
128            PromptTemplate {
129                name: "planning".to_string(),
130                content: include_str!("../prompt_templates/planning.hbs").to_string(),
131                description: Some("Default system message template".to_string()),
132                version: Some("1.0.0".to_string()),
133            },
134            PromptTemplate {
135                name: "user".to_string(),
136                content: include_str!("../prompt_templates/user.hbs").to_string(),
137                description: Some("Default user message template".to_string()),
138                version: Some("1.0.0".to_string()),
139            },
140            PromptTemplate {
141                name: "code".to_string(),
142                content: include_str!("../prompt_templates/code.hbs").to_string(),
143                description: Some("Code generation template".to_string()),
144                version: Some("1.0.0".to_string()),
145            },
146            PromptTemplate {
147                name: "reflection".to_string(),
148                content: include_str!("../prompt_templates/reflection.hbs").to_string(),
149                description: Some("Reflection and improvement template".to_string()),
150                version: Some("1.0.0".to_string()),
151            },
152            PromptTemplate {
153                name: "standard_user_message".to_string(),
154                content: include_str!("../prompt_templates/user.hbs").to_string(),
155                description: Some("Standard user message template".to_string()),
156                version: Some("1.0.0".to_string()),
157            },
158        ];
159
160        let mut templates_lock = self.templates.write().await;
161        for template in templates {
162            templates_lock.insert(template.name.clone(), template);
163        }
164
165        Ok(())
166    }
167
168    async fn register_static_partials(&self) -> Result<(), AgentError> {
169        let partials = vec![
170            (
171                "core_instructions",
172                include_str!("../prompt_templates/partials/core_instructions.hbs"),
173            ),
174            (
175                "communication",
176                include_str!("../prompt_templates/partials/communication.hbs"),
177            ),
178            (
179                "todo_instructions",
180                include_str!("../prompt_templates/partials/todo_instructions.hbs"),
181            ),
182            (
183                "tools_xml",
184                include_str!("../prompt_templates/partials/tools_xml.hbs"),
185            ),
186            (
187                "tools_json",
188                include_str!("../prompt_templates/partials/tools_json.hbs"),
189            ),
190            (
191                "reasoning",
192                include_str!("../prompt_templates/partials/reasoning.hbs"),
193            ),
194            (
195                "skills",
196                include_str!("../prompt_templates/partials/skills.hbs"),
197            ),
198            (
199                "connections",
200                include_str!("../prompt_templates/partials/connections.hbs"),
201            ),
202            (
203                "sub_agents",
204                include_str!("../prompt_templates/partials/sub_agents.hbs"),
205            ),
206            (
207                "static_prefix",
208                include_str!("../prompt_templates/partials/static_prefix.hbs"),
209            ),
210            (
211                "dynamic_suffix",
212                include_str!("../prompt_templates/partials/dynamic_suffix.hbs"),
213            ),
214            (
215                "channel_formatting",
216                include_str!("../prompt_templates/partials/channel_formatting.hbs"),
217            ),
218        ];
219
220        let mut partials_lock = self.partials.write().await;
221        for (name, content) in partials {
222            partials_lock.insert(name.to_string(), content.to_string());
223        }
224
225        Ok(())
226    }
227
228    pub async fn register_template(&self, template: PromptTemplate) -> Result<(), AgentError> {
229        let mut templates = self.templates.write().await;
230        templates.insert(template.name.clone(), template);
231        Ok(())
232    }
233
234    pub async fn register_template_string(
235        &self,
236        name: String,
237        content: String,
238        description: Option<String>,
239        version: Option<String>,
240    ) -> Result<(), AgentError> {
241        let template = PromptTemplate {
242            name: name.clone(),
243            content,
244            description,
245            version,
246        };
247        self.register_template(template).await
248    }
249
250    pub fn get_default_templates() -> Vec<crate::stores::NewPromptTemplate> {
251        vec![
252            crate::stores::NewPromptTemplate {
253                name: "planning".to_string(),
254                template: include_str!("../prompt_templates/planning.hbs").to_string(),
255                description: Some("Default system message template".to_string()),
256                version: Some("1.0.0".to_string()),
257                is_system: true,
258            },
259            crate::stores::NewPromptTemplate {
260                name: "user".to_string(),
261                template: include_str!("../prompt_templates/user.hbs").to_string(),
262                description: Some("Default user message template".to_string()),
263                version: Some("1.0.0".to_string()),
264                is_system: true,
265            },
266            crate::stores::NewPromptTemplate {
267                name: "code".to_string(),
268                template: include_str!("../prompt_templates/code.hbs").to_string(),
269                description: Some("Code generation template".to_string()),
270                version: Some("1.0.0".to_string()),
271                is_system: true,
272            },
273            crate::stores::NewPromptTemplate {
274                name: "reflection".to_string(),
275                template: include_str!("../prompt_templates/reflection.hbs").to_string(),
276                description: Some("Reflection and improvement template".to_string()),
277                version: Some("1.0.0".to_string()),
278                is_system: true,
279            },
280            crate::stores::NewPromptTemplate {
281                name: "standard_user_message".to_string(),
282                template: include_str!("../prompt_templates/user.hbs").to_string(),
283                description: Some("Standard user message template".to_string()),
284                version: Some("1.0.0".to_string()),
285                is_system: true,
286            },
287        ]
288    }
289
290    pub async fn register_template_file<P: AsRef<Path>>(
291        &self,
292        name: String,
293        file_path: P,
294        description: Option<String>,
295        version: Option<String>,
296    ) -> Result<(), AgentError> {
297        let path = file_path.as_ref();
298        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
299            AgentError::Planning(format!(
300                "Failed to read template file '{}': {}",
301                path.display(),
302                e
303            ))
304        })?;
305
306        let template = PromptTemplate {
307            name: name.clone(),
308            content,
309            description,
310            version,
311        };
312        self.register_template(template).await
313    }
314
315    pub async fn register_partial(&self, name: String, content: String) -> Result<(), AgentError> {
316        let mut partials = self.partials.write().await;
317        partials.insert(name, content);
318        Ok(())
319    }
320
321    /// Return the set of currently registered partial names.
322    pub async fn partial_names(&self) -> std::collections::HashSet<String> {
323        let partials = self.partials.read().await;
324        partials.keys().cloned().collect()
325    }
326
327    pub async fn register_partial_file<P: AsRef<Path>>(
328        &self,
329        name: String,
330        file_path: P,
331    ) -> Result<(), AgentError> {
332        let path = file_path.as_ref();
333        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
334            AgentError::Planning(format!(
335                "Failed to read partial file '{}': {}",
336                path.display(),
337                e
338            ))
339        })?;
340        self.register_partial(name, content).await
341    }
342
343    pub async fn register_templates_from_directory<P: AsRef<Path>>(
344        &self,
345        dir_path: P,
346    ) -> Result<(), AgentError> {
347        let path = dir_path.as_ref();
348        if !path.exists() {
349            return Ok(());
350        }
351
352        let mut entries = tokio::fs::read_dir(path).await.map_err(|e| {
353            AgentError::Planning(format!(
354                "Failed to read directory '{}': {}",
355                path.display(),
356                e
357            ))
358        })?;
359
360        while let Some(entry) = entries
361            .next_entry()
362            .await
363            .map_err(|e| AgentError::Planning(format!("Failed to read directory entry: {}", e)))?
364        {
365            let entry_path = entry.path();
366            if entry_path.is_file()
367                && let Some(extension) = entry_path.extension()
368                && (extension == "hbs" || extension == "handlebars")
369                && let Some(stem) = entry_path.file_stem()
370            {
371                let name = stem.to_string_lossy().to_string();
372                tracing::debug!(
373                    "Registering template '{}' from '{}'",
374                    name,
375                    entry_path.display()
376                );
377                self.register_template_file(name, &entry_path, None, None)
378                    .await?;
379            }
380        }
381
382        Ok(())
383    }
384
385    pub async fn register_partials_from_directory<P: AsRef<Path>>(
386        &self,
387        dir_path: P,
388    ) -> Result<(), AgentError> {
389        let path = dir_path.as_ref();
390        if !path.exists() {
391            return Ok(());
392        }
393
394        let mut entries = tokio::fs::read_dir(path).await.map_err(|e| {
395            AgentError::Planning(format!(
396                "Failed to read directory '{}': {}",
397                path.display(),
398                e
399            ))
400        })?;
401
402        while let Some(entry) = entries
403            .next_entry()
404            .await
405            .map_err(|e| AgentError::Planning(format!("Failed to read directory entry: {}", e)))?
406        {
407            let entry_path = entry.path();
408            if entry_path.is_file()
409                && let Some(extension) = entry_path.extension()
410                && (extension == "hbs" || extension == "handlebars")
411                && let Some(stem) = entry_path.file_stem()
412            {
413                let name = stem.to_string_lossy().to_string();
414                tracing::debug!(
415                    "Registering partial '{}' from '{}'",
416                    name,
417                    entry_path.display()
418                );
419                self.register_partial_file(name, &entry_path).await?;
420            }
421        }
422
423        Ok(())
424    }
425
426    pub async fn get_template(&self, name: &str) -> Option<PromptTemplate> {
427        let templates = self.templates.read().await;
428        templates.get(name).cloned()
429    }
430
431    pub async fn get_partial(&self, name: &str) -> Option<String> {
432        let partials = self.partials.read().await;
433        partials.get(name).cloned()
434    }
435
436    pub async fn list_templates(&self) -> Vec<String> {
437        let templates = self.templates.read().await;
438        templates.keys().cloned().collect()
439    }
440
441    pub async fn list_partials(&self) -> Vec<String> {
442        let partials = self.partials.read().await;
443        partials.keys().cloned().collect()
444    }
445
446    pub async fn get_all_templates(&self) -> HashMap<String, PromptTemplate> {
447        let templates = self.templates.read().await;
448        templates.clone()
449    }
450
451    pub async fn get_all_partials(&self) -> HashMap<String, String> {
452        let partials = self.partials.read().await;
453        partials.clone()
454    }
455
456    pub async fn clear(&self) {
457        {
458            let mut templates = self.templates.write().await;
459            templates.clear();
460        }
461        {
462            let mut partials = self.partials.write().await;
463            partials.clear();
464        }
465        // Also clear section caches
466        self.clear_section_cache().await;
467    }
468
469    pub async fn remove_template(&self, name: &str) -> Option<PromptTemplate> {
470        let mut templates = self.templates.write().await;
471        templates.remove(name)
472    }
473
474    pub async fn remove_partial(&self, name: &str) -> Option<String> {
475        let mut partials = self.partials.write().await;
476        partials.remove(name)
477    }
478
479    pub async fn configure_handlebars(
480        &self,
481        handlebars: &mut handlebars::Handlebars<'_>,
482    ) -> Result<(), AgentError> {
483        handlebars_helper!(eq: |x: str, y: str| x == y);
484        handlebars.register_helper("eq", Box::new(eq));
485        let partials = self.partials.read().await;
486        for (name, content) in partials.iter() {
487            handlebars.register_partial(name, content).map_err(|e| {
488                AgentError::Planning(format!("Failed to register partial '{}': {}", name, e))
489            })?;
490        }
491        Ok(())
492    }
493
494    pub async fn render_template<'a>(
495        &self,
496        template: &str,
497        template_data: &TemplateData<'a>,
498    ) -> Result<String, AgentError> {
499        let mut handlebars = Handlebars::new();
500        handlebars.set_strict_mode(true);
501
502        self.configure_handlebars(&mut handlebars).await?;
503        let rendered = handlebars
504            .render_template(template, &template_data)
505            .map_err(|e| AgentError::Planning(format!("Failed to render template: {}", e)))?;
506        Ok(rendered)
507    }
508
509    /// Render a template and return the result with token budget information.
510    /// This is the budget-aware version that tracks per-component token usage.
511    pub async fn render_template_with_budget<'a>(
512        &self,
513        template: &str,
514        template_data: &TemplateData<'a>,
515    ) -> Result<RenderResult, AgentError> {
516        let rendered = self.render_template(template, template_data).await?;
517        let estimated_tokens = rough_token_count(&rendered);
518
519        Ok(RenderResult {
520            content: rendered,
521            estimated_tokens,
522        })
523    }
524
525    pub async fn validate_template(&self, template: &str) -> Result<(), AgentError> {
526        let mut handlebars = Handlebars::new();
527        handlebars.set_strict_mode(true);
528        self.configure_handlebars(&mut handlebars).await?;
529        let sample_template_data = TemplateData::default();
530        handlebars
531            .render_template(template, &sample_template_data)
532            .map(|_| ())
533            .map_err(|e| AgentError::Planning(format!("Failed to render template: {}", e)))
534    }
535}
536
537/// Parse-only validation for a user-authored handlebars template (skill
538/// body, agent `instructions`, or stored prompt-template content).
539///
540/// Catches real syntax errors — unbalanced `{{` / `}}`, malformed block
541/// helpers (`{{#if …}}` without `{{/if}}`), broken partial directives — so
542/// authors see template errors at push time instead of at the next agent
543/// run.
544///
545/// Does NOT attempt a strict-mode render: at push time we only have the
546/// built-in partials registered, but templates legitimately reference
547/// workspace-scoped partials (other prompt-templates, skill includes) and
548/// runtime-only variables (`dynamic_values`, `session_values`, per-tool
549/// fields). Those resolve at render time against the live `PromptRegistry`
550/// + `TemplateData`, not against `TemplateData::default()`.
551///
552/// Empty input is a no-op.
553pub fn validate_template_content(content: &str) -> Result<(), AgentError> {
554    if content.trim().is_empty() {
555        return Ok(());
556    }
557    let mut handlebars = Handlebars::new();
558    handlebars
559        .register_template_string("__validation__", content)
560        .map_err(|e| AgentError::Planning(format!("Invalid handlebars template: {}", e)))
561}
562
563impl Default for PromptRegistry {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569impl PromptRegistry {
570    /// Render the static prefix zone and cache its hash.
571    /// Returns (rendered_content, hash, estimated_tokens).
572    /// Subsequent calls with the same template data return the cached result.
573    pub async fn render_static_prefix<'a>(
574        &self,
575        template_data: &TemplateData<'a>,
576    ) -> Result<(String, String, usize), AgentError> {
577        let cache_key = "static_prefix".to_string();
578
579        // Check section cache first
580        {
581            let cache = self.section_cache.read().await;
582            if let Some((content, tokens)) = cache.get(&cache_key) {
583                let hash = self.static_prefix_hash.read().await;
584                if let Some(h) = hash.as_ref() {
585                    return Ok((content.clone(), h.clone(), *tokens));
586                }
587            }
588        }
589
590        // Render the static prefix partial
591        let static_template = "{{> static_prefix}}";
592        let rendered = self.render_template(static_template, template_data).await?;
593        let tokens = rough_token_count(&rendered);
594
595        // Compute hash for API-side caching
596        let hash = compute_hash(&rendered);
597
598        // Cache the result
599        {
600            let mut cache = self.section_cache.write().await;
601            cache.insert(cache_key, (rendered.clone(), tokens));
602        }
603        {
604            let mut hash_lock = self.static_prefix_hash.write().await;
605            *hash_lock = Some(hash.clone());
606        }
607
608        Ok((rendered, hash, tokens))
609    }
610
611    /// Render the dynamic suffix zone. This is NOT cached between turns.
612    pub async fn render_dynamic_suffix<'a>(
613        &self,
614        template_data: &TemplateData<'a>,
615    ) -> Result<(String, usize), AgentError> {
616        let dynamic_template = "{{> dynamic_suffix}}";
617        let rendered = self
618            .render_template(dynamic_template, template_data)
619            .await?;
620        let tokens = rough_token_count(&rendered);
621        Ok((rendered, tokens))
622    }
623
624    /// Render a section and cache the result. Returns (content, token_count).
625    /// If the section was previously rendered with the same key, returns cached.
626    pub async fn render_section_cached<'a>(
627        &self,
628        section_key: &str,
629        template: &str,
630        template_data: &TemplateData<'a>,
631    ) -> Result<(String, usize), AgentError> {
632        // Check cache
633        {
634            let cache = self.section_cache.read().await;
635            if let Some((content, tokens)) = cache.get(section_key) {
636                return Ok((content.clone(), *tokens));
637            }
638        }
639
640        let rendered = self.render_template(template, template_data).await?;
641        let tokens = rough_token_count(&rendered);
642
643        // Store in cache
644        {
645            let mut cache = self.section_cache.write().await;
646            cache.insert(section_key.to_string(), (rendered.clone(), tokens));
647        }
648
649        Ok((rendered, tokens))
650    }
651
652    /// Invalidate a specific section cache entry.
653    pub async fn invalidate_section(&self, section_key: &str) {
654        let mut cache = self.section_cache.write().await;
655        cache.remove(section_key);
656    }
657
658    /// Clear all section caches. Call this on /clear or /compact.
659    pub async fn clear_section_cache(&self) {
660        let mut cache = self.section_cache.write().await;
661        cache.clear();
662        let mut hash = self.static_prefix_hash.write().await;
663        *hash = None;
664    }
665
666    /// Get the cached static prefix hash, if available.
667    pub async fn get_static_prefix_hash(&self) -> Option<String> {
668        self.static_prefix_hash.read().await.clone()
669    }
670}
671
672/// Compute a simple hash of content for cache tracking.
673/// Uses a fast non-cryptographic hash (FNV-1a style).
674fn compute_hash(content: &str) -> String {
675    let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis
676    for byte in content.bytes() {
677        hash ^= byte as u64;
678        hash = hash.wrapping_mul(0x100000001b3); // FNV prime
679    }
680    format!("{:016x}", hash)
681}
682
683/// Fast rough token count: ~4 chars per token.
684/// Standalone function for use outside of distri-core's TokenEstimator.
685#[inline]
686pub fn rough_token_count(text: &str) -> usize {
687    text.len().div_ceil(4)
688}
689
690/// Result of rendering a template with budget tracking.
691#[derive(Debug, Clone)]
692pub struct RenderResult {
693    pub content: String,
694    pub estimated_tokens: usize,
695}
696
697/// Render a system/user prompt pair into model-ready messages.
698pub async fn build_prompt_messages<'a>(
699    registry: &PromptRegistry,
700    system_template: &str,
701    user_template: &str,
702    template_data: &TemplateData<'a>,
703    user_message: &Message,
704) -> Result<Vec<Message>, AgentError> {
705    let rendered_system = registry
706        .render_template(system_template, template_data)
707        .await?;
708    let rendered_user = registry
709        .render_template(user_template, template_data)
710        .await?;
711
712    let system_msg = Message::system(rendered_system, None);
713
714    let mut user_msg = user_message.clone();
715    if user_msg.parts.is_empty()
716        && let Some(text) = user_message.as_text()
717    {
718        user_msg.parts.push(Part::Text(text));
719    }
720    if !rendered_user.is_empty() {
721        user_msg.parts.push(Part::Text(rendered_user));
722    }
723
724    Ok(vec![system_msg, user_msg])
725}
726
727/// Result of building prompt messages with budget tracking.
728#[derive(Debug, Clone)]
729pub struct PromptBuildResult {
730    pub messages: Vec<Message>,
731    pub budget: ContextBudget,
732}
733
734/// Build prompt messages with per-component token budget tracking.
735///
736/// Returns both the messages and a `ContextBudget` snapshot showing
737/// how many tokens each component consumes. This enables:
738/// - Monitoring context utilization per turn
739/// - Triggering compaction/deferral when thresholds are exceeded
740/// - Optimizing which components to include
741pub async fn build_prompt_messages_with_budget<'a>(
742    registry: &PromptRegistry,
743    system_template: &str,
744    user_template: &str,
745    template_data: &TemplateData<'a>,
746    user_message: &Message,
747    context_window_size: usize,
748) -> Result<PromptBuildResult, AgentError> {
749    let system_result = registry
750        .render_template_with_budget(system_template, template_data)
751        .await?;
752    let user_result = registry
753        .render_template_with_budget(user_template, template_data)
754        .await?;
755
756    let system_msg = Message::system(system_result.content, None);
757
758    let mut user_msg = user_message.clone();
759    if user_msg.parts.is_empty()
760        && let Some(text) = user_message.as_text()
761    {
762        user_msg.parts.push(Part::Text(text));
763    }
764    if !user_result.content.is_empty() {
765        user_msg.parts.push(Part::Text(user_result.content));
766    }
767
768    // Estimate per-component token usage from template data
769    let tool_schema_tokens = rough_token_count(&template_data.available_tools);
770    let skill_listing_tokens = template_data
771        .available_skills
772        .as_ref()
773        .map(|s| rough_token_count(s))
774        .unwrap_or(0);
775
776    // The system prompt total minus tools and skills gives us the prompt itself
777    let prompt_only_tokens = system_result
778        .estimated_tokens
779        .saturating_sub(tool_schema_tokens)
780        .saturating_sub(skill_listing_tokens);
781
782    // Split prompt tokens: static portions (core_instructions, communication, etc.)
783    // vs dynamic (dynamic_sections, scratchpad, todos, step limits)
784    // Heuristic: dynamic_sections + scratchpad + todos are dynamic
785    let dynamic_content_tokens = {
786        let mut dynamic_chars = 0;
787        for section in &template_data.dynamic_sections {
788            dynamic_chars += section.content.len();
789        }
790        dynamic_chars += template_data.scratchpad.len();
791        if let Some(todos) = &template_data.todos {
792            dynamic_chars += todos.len();
793        }
794        dynamic_chars.div_ceil(4)
795    };
796    let static_tokens = prompt_only_tokens.saturating_sub(dynamic_content_tokens);
797
798    let budget = ContextBudget {
799        system_prompt_static_tokens: static_tokens,
800        system_prompt_dynamic_tokens: dynamic_content_tokens,
801        tool_schema_tokens,
802        deferred_tool_tokens: 0, // Set by tool resolution layer
803        skill_listing_tokens,
804        conversation_tokens: 0, // Set by caller (LLM executor)
805        tool_result_tokens: 0,  // Set by caller
806        context_window_size,
807        static_prefix_cache_hit: false,
808        static_prefix_hash: None,
809    };
810
811    if budget.is_warning() {
812        tracing::warn!(
813            "Context budget warning: {:.1}% utilization ({}/{} tokens). \
814             system_static={}, system_dynamic={}, tools={}, skills={}",
815            budget.utilization() * 100.0,
816            budget.total_tokens(),
817            context_window_size,
818            budget.system_prompt_static_tokens,
819            budget.system_prompt_dynamic_tokens,
820            budget.tool_schema_tokens,
821            budget.skill_listing_tokens,
822        );
823    }
824
825    Ok(PromptBuildResult {
826        messages: vec![system_msg, user_msg],
827        budget,
828    })
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn validate_template_content_accepts_unknown_partials_and_variables() {
837        // Workspace-scoped partials and runtime-only variables aren't
838        // available at push time — they resolve at render. The validator
839        // must not reject them.
840        validate_template_content("{{> workspace_partial}}").unwrap();
841        validate_template_content("{{some_runtime_var}}").unwrap();
842        validate_template_content("{{session.key}} {{dynamic_values.foo}}").unwrap();
843        validate_template_content("{{#if (eq runtime_mode \"cli\")}}cli{{/if}}").unwrap();
844    }
845
846    #[test]
847    fn validate_template_content_rejects_syntax_errors() {
848        // Unclosed block helper.
849        assert!(validate_template_content("{{#if foo}}no close").is_err());
850        // Mismatched braces.
851        assert!(validate_template_content("{{foo").is_err());
852    }
853
854    #[test]
855    fn validate_template_content_empty_is_ok() {
856        validate_template_content("").unwrap();
857        validate_template_content("   \n  ").unwrap();
858    }
859
860    #[tokio::test]
861    async fn renders_templates_and_messages() {
862        let registry = PromptRegistry::with_defaults().await.unwrap();
863        let data = TemplateData {
864            description: "desc".into(),
865            instructions: "be nice".into(),
866            available_tools: "none".into(),
867            task: "task".into(),
868            scratchpad: String::new(),
869            dynamic_sections: vec![],
870            dynamic_values: HashMap::new(),
871            session_values: HashMap::new(),
872            reasoning_depth: "standard",
873            execution_mode: "tools",
874            tool_format: "json",
875            show_examples: false,
876            max_steps: 5,
877            current_steps: 0,
878            remaining_steps: 5,
879            todos: None,
880            json_tools: true,
881            available_skills: None,
882            tool_prompts: String::new(),
883            tool_prompt_list: vec![],
884            deferred_tools_listing: None,
885            channel_kind: None,
886            runtime_mode: "",
887        };
888        let msgs = build_prompt_messages(
889            &registry,
890            "{{instructions}}",
891            "task: {{task}}",
892            &data,
893            &Message::user("hello".into(), None),
894        )
895        .await
896        .unwrap();
897        assert_eq!(msgs.len(), 2);
898        assert!(msgs[0].as_text().unwrap().contains("be nice"));
899        assert!(msgs[1].as_text().unwrap().contains("task"));
900    }
901
902    #[test]
903    fn test_rough_token_count() {
904        assert_eq!(rough_token_count(""), 0);
905        assert_eq!(rough_token_count("abcd"), 1); // 4 chars = 1 token
906        assert_eq!(rough_token_count("Hello world"), 3); // 11 chars ≈ 3 tokens
907        assert_eq!(rough_token_count("a"), 1); // 1 char rounds up to 1 token
908    }
909
910    #[tokio::test]
911    async fn test_render_template_with_budget() {
912        let registry = PromptRegistry::with_defaults().await.unwrap();
913        let data = TemplateData {
914            instructions: "Test instructions here".into(),
915            ..Default::default()
916        };
917        let result = registry
918            .render_template_with_budget("{{instructions}}", &data)
919            .await
920            .unwrap();
921
922        assert_eq!(result.content, "Test instructions here");
923        assert!(result.estimated_tokens > 0);
924        // ~22 chars / 4 ≈ 6 tokens
925        assert_eq!(result.estimated_tokens, 6);
926    }
927
928    #[tokio::test]
929    async fn test_section_cache_returns_cached_value() {
930        let registry = PromptRegistry::with_defaults().await.unwrap();
931        let data = TemplateData {
932            instructions: "cached content".into(),
933            ..Default::default()
934        };
935
936        // First render
937        let (content1, tokens1) = registry
938            .render_section_cached("test_section", "{{instructions}}", &data)
939            .await
940            .unwrap();
941
942        // Second render should return cached value
943        let (content2, tokens2) = registry
944            .render_section_cached("test_section", "{{instructions}}", &data)
945            .await
946            .unwrap();
947
948        assert_eq!(content1, content2);
949        assert_eq!(tokens1, tokens2);
950        assert_eq!(content1, "cached content");
951    }
952
953    #[tokio::test]
954    async fn test_section_cache_invalidation() {
955        let registry = PromptRegistry::with_defaults().await.unwrap();
956        let data = TemplateData {
957            instructions: "original".into(),
958            ..Default::default()
959        };
960
961        let (content1, _) = registry
962            .render_section_cached("test_section", "{{instructions}}", &data)
963            .await
964            .unwrap();
965        assert_eq!(content1, "original");
966
967        // Invalidate
968        registry.invalidate_section("test_section").await;
969
970        // Re-render with new data
971        let data2 = TemplateData {
972            instructions: "updated".into(),
973            ..Default::default()
974        };
975        let (content2, _) = registry
976            .render_section_cached("test_section", "{{instructions}}", &data2)
977            .await
978            .unwrap();
979        assert_eq!(content2, "updated");
980    }
981
982    #[tokio::test]
983    async fn test_build_prompt_messages_with_budget() {
984        let registry = PromptRegistry::with_defaults().await.unwrap();
985        let data = TemplateData {
986            instructions: "be helpful".into(),
987            available_tools: "tool1, tool2".into(),
988            task: "do something".into(),
989            ..Default::default()
990        };
991
992        let result = build_prompt_messages_with_budget(
993            &registry,
994            "{{instructions}}\n{{available_tools}}",
995            "{{task}}",
996            &data,
997            &Message::user("hello".into(), None),
998            200_000,
999        )
1000        .await
1001        .unwrap();
1002
1003        assert_eq!(result.messages.len(), 2);
1004        assert_eq!(result.budget.context_window_size, 200_000);
1005        assert!(result.budget.tool_schema_tokens > 0);
1006        assert!(!result.budget.is_warning());
1007    }
1008
1009    #[test]
1010    fn test_compute_hash_deterministic() {
1011        let hash1 = compute_hash("test content");
1012        let hash2 = compute_hash("test content");
1013        assert_eq!(hash1, hash2);
1014
1015        let hash3 = compute_hash("different content");
1016        assert_ne!(hash1, hash3);
1017    }
1018}