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#[derive(Debug, Clone)]
21pub struct PromptRegistry {
22 templates: Arc<RwLock<HashMap<String, PromptTemplate>>>,
23 partials: Arc<RwLock<HashMap<String, String>>>,
24 section_cache: Arc<RwLock<HashMap<String, (String, usize)>>>,
26 static_prefix_hash: Arc<RwLock<Option<String>>>,
28}
29
30#[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 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 #[serde(default)]
62 pub available_skills: Option<String>,
63 #[serde(default)]
66 pub tool_prompts: String,
67 #[serde(default)]
70 pub tool_prompt_list: Vec<ToolPromptEntry>,
71 #[serde(default)]
75 pub deferred_tools_listing: Option<String>,
76 #[serde(default)]
82 pub channel_kind: Option<String>,
83 #[serde(default)]
92 pub runtime_mode: &'a str,
93}
94
95#[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 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 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 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 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
537pub 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 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 {
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 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 let hash = compute_hash(&rendered);
597
598 {
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 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 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 {
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 {
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 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 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 pub async fn get_static_prefix_hash(&self) -> Option<String> {
668 self.static_prefix_hash.read().await.clone()
669 }
670}
671
672fn compute_hash(content: &str) -> String {
675 let mut hash: u64 = 0xcbf29ce484222325; for byte in content.bytes() {
677 hash ^= byte as u64;
678 hash = hash.wrapping_mul(0x100000001b3); }
680 format!("{:016x}", hash)
681}
682
683#[inline]
686pub fn rough_token_count(text: &str) -> usize {
687 text.len().div_ceil(4)
688}
689
690#[derive(Debug, Clone)]
692pub struct RenderResult {
693 pub content: String,
694 pub estimated_tokens: usize,
695}
696
697pub 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#[derive(Debug, Clone)]
729pub struct PromptBuildResult {
730 pub messages: Vec<Message>,
731 pub budget: ContextBudget,
732}
733
734pub 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 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 let prompt_only_tokens = system_result
778 .estimated_tokens
779 .saturating_sub(tool_schema_tokens)
780 .saturating_sub(skill_listing_tokens);
781
782 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, skill_listing_tokens,
804 conversation_tokens: 0, tool_result_tokens: 0, 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 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 assert!(validate_template_content("{{#if foo}}no close").is_err());
850 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 ®istry,
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); assert_eq!(rough_token_count("Hello world"), 3); assert_eq!(rough_token_count("a"), 1); }
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 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 let (content1, tokens1) = registry
938 .render_section_cached("test_section", "{{instructions}}", &data)
939 .await
940 .unwrap();
941
942 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 registry.invalidate_section("test_section").await;
969
970 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 ®istry,
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}