Skip to main content

matrixcode_core/prompt/
orchestrator.rs

1//! Prompt Orchestrator
2//!
3//! Core prompt assembly system that:
4//! - Composes sections in order
5//! - Inserts cache boundary markers
6//! - Manages caching for static sections
7//! - Injects runtime context
8
9use crate::prompt::{CacheKey, PromptSection, SectionCache};
10use crate::prompt::{ContextInjector, SystemContext, UserContext};
11use std::sync::Arc;
12
13/// Cache boundary marker for API caching
14///
15/// This marker indicates where cached content ends and dynamic content begins.
16/// APIs like Claude can use this for prompt prefix caching.
17pub const CACHE_BOUNDARY: &str = "\n<!-- CACHE_BOUNDARY -->\n";
18
19/// Prompt profile type
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum PromptProfile {
22    /// Default profile (full capabilities)
23    Default,
24    /// Safe profile (restricted operations)
25    Safe,
26    /// Fast profile (minimal prompt)
27    Fast,
28    /// Review profile (code review focus)
29    Review,
30}
31
32impl PromptProfile {
33    pub fn as_str(&self) -> &'static str {
34        match self {
35            Self::Default => "default",
36            Self::Safe => "safe",
37            Self::Fast => "fast",
38            Self::Review => "review",
39        }
40    }
41
42    pub fn from_str(s: &str) -> Self {
43        match s {
44            "safe" => Self::Safe,
45            "fast" => Self::Fast,
46            "review" => Self::Review,
47            _ => Self::Default,
48        }
49    }
50}
51
52impl std::fmt::Display for PromptProfile {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.as_str())
55    }
56}
57
58impl Default for PromptProfile {
59    fn default() -> Self {
60        Self::Default
61    }
62}
63
64/// Prompt orchestrator - manages prompt assembly
65pub struct PromptOrchestrator {
66    /// Cache for static sections
67    cache: Arc<SectionCache>,
68    /// Context injector
69    context_injector: ContextInjector,
70    /// Current profile
71    profile: PromptProfile,
72    /// Sections to include
73    sections: Vec<PromptSection>,
74    /// Whether to include cache boundary
75    include_boundary: bool,
76    /// Whether to inject context
77    inject_context: bool,
78}
79
80impl PromptOrchestrator {
81    /// Create a new orchestrator
82    pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
83        Self {
84            cache: Arc::new(SectionCache::new()),
85            context_injector: ContextInjector::new(working_dir.into()),
86            profile: PromptProfile::Default,
87            sections: Vec::new(),
88            include_boundary: true,
89            inject_context: true,
90        }
91    }
92
93    /// Create with shared cache
94    pub fn with_cache(mut self, cache: Arc<SectionCache>) -> Self {
95        self.cache = cache;
96        self
97    }
98
99    /// Set profile
100    pub fn with_profile(mut self, profile: PromptProfile) -> Self {
101        self.profile = profile;
102        self
103    }
104
105    /// Set whether to include cache boundary
106    pub fn with_boundary(mut self, include: bool) -> Self {
107        self.include_boundary = include;
108        self
109    }
110
111    /// Set whether to inject context
112    pub fn with_context_injection(mut self, inject: bool) -> Self {
113        self.inject_context = inject;
114        self
115    }
116
117    /// Add a section
118    pub fn add_section(&mut self, section: PromptSection) -> &mut Self {
119        self.sections.push(section);
120        self
121    }
122
123    /// Add multiple sections
124    pub fn add_sections(&mut self, sections: Vec<PromptSection>) -> &mut Self {
125        self.sections.extend(sections);
126        self
127    }
128
129    /// Clear all sections
130    pub fn clear_sections(&mut self) -> &mut Self {
131        self.sections.clear();
132        self
133    }
134
135    /// Invalidate cache (e.g., when context changes)
136    pub fn invalidate_cache(&mut self) {
137        self.cache.clear();
138        self.context_injector.invalidate();
139    }
140
141    /// Render a section with caching
142    fn render_section(&self, section: &PromptSection) -> String {
143        if section.cacheable {
144            let key = CacheKey::new(&section.name, self.profile.as_str());
145            self.cache
146                .get_or_compute(&key, || section.compute_content())
147        } else {
148            section.compute_content()
149        }
150    }
151
152    /// Assemble the full prompt
153    pub fn assemble(&mut self) -> AssembledPrompt {
154        let mut cached_parts = Vec::new();
155        let mut dynamic_parts = Vec::new();
156        let mut cached_tokens = 0;
157        let mut dynamic_tokens = 0;
158
159        // Sort sections by order
160        let mut sections = self.sections.clone();
161        sections.sort_by_key(|s| s.order);
162
163        // Process each section
164        for section in &sections {
165            let content = self.render_section(section);
166
167            if section.cacheable {
168                cached_parts.push((section.name.clone(), content.clone()));
169                cached_tokens += self.estimate_tokens(&content);
170            } else {
171                dynamic_parts.push((section.name.clone(), content.clone()));
172                dynamic_tokens += self.estimate_tokens(&content);
173            }
174        }
175
176        // Inject runtime context if enabled
177        let context_section = if self.inject_context {
178            let ctx = self.context_injector.render_full_context();
179            dynamic_tokens += self.estimate_tokens(&ctx);
180            Some(ctx)
181        } else {
182            None
183        };
184
185        // Assemble final prompt
186        let mut final_parts = Vec::new();
187
188        // Add cached parts first
189        for (name, content) in &cached_parts {
190            if !content.is_empty() {
191                final_parts.push(format!("[{}]\n{}", name, content));
192            }
193        }
194
195        // Add cache boundary if there are both cached and dynamic parts
196        if self.include_boundary
197            && !cached_parts.is_empty()
198            && (!dynamic_parts.is_empty() || context_section.is_some())
199        {
200            final_parts.push(CACHE_BOUNDARY.to_string());
201        }
202
203        // Add dynamic parts
204        for (name, content) in &dynamic_parts {
205            if !content.is_empty() {
206                final_parts.push(format!("[{}]\n{}", name, content));
207            }
208        }
209
210        // Add context section
211        if let Some(ctx) = context_section {
212            final_parts.push(ctx);
213        }
214
215        let full_prompt = final_parts.join("\n\n");
216
217        // Get cache stats
218        let stats = self.cache.stats();
219
220        AssembledPrompt {
221            prompt: full_prompt,
222            cached_sections: cached_parts.len(),
223            dynamic_sections: dynamic_parts.len(),
224            cached_tokens,
225            dynamic_tokens,
226            total_tokens: cached_tokens + dynamic_tokens,
227            cache_hit_rate: stats.hit_rate(),
228            profile: self.profile,
229        }
230    }
231
232    /// Assemble prompt for specific profile
233    pub fn assemble_for_profile(&mut self, profile: PromptProfile) -> AssembledPrompt {
234        self.profile = profile;
235        self.assemble()
236    }
237
238    /// Estimate token count
239    fn estimate_tokens(&self, content: &str) -> usize {
240        crate::prompt::cache::estimate_tokens(content)
241    }
242
243    /// Get cache statistics
244    pub fn cache_stats(&self) -> crate::prompt::cache::CacheStats {
245        self.cache.stats()
246    }
247
248    /// Get user context
249    pub fn get_user_context(&mut self) -> &UserContext {
250        self.context_injector.get_user_context()
251    }
252
253    /// Get system context
254    pub fn get_system_context(&mut self) -> &SystemContext {
255        self.context_injector.get_system_context()
256    }
257}
258
259/// Assembled prompt with metadata
260#[derive(Debug, Clone)]
261pub struct AssembledPrompt {
262    /// The full prompt text
263    pub prompt: String,
264    /// Number of cached sections
265    pub cached_sections: usize,
266    /// Number of dynamic sections
267    pub dynamic_sections: usize,
268    /// Estimated cached tokens
269    pub cached_tokens: usize,
270    /// Estimated dynamic tokens
271    pub dynamic_tokens: usize,
272    /// Total estimated tokens
273    pub total_tokens: usize,
274    /// Cache hit rate
275    pub cache_hit_rate: f64,
276    /// Profile used
277    pub profile: PromptProfile,
278}
279
280impl AssembledPrompt {
281    /// Check if prompt is empty
282    pub fn is_empty(&self) -> bool {
283        self.prompt.is_empty()
284    }
285
286    /// Get prompt length in characters
287    pub fn len(&self) -> usize {
288        self.prompt.len()
289    }
290
291    /// Get cache efficiency percentage
292    pub fn cache_efficiency(&self) -> f64 {
293        if self.total_tokens == 0 {
294            0.0
295        } else {
296            (self.cached_tokens as f64 / self.total_tokens as f64) * 100.0
297        }
298    }
299
300    /// Split prompt at cache boundary
301    pub fn split_at_boundary(&self) -> (Option<&str>, Option<&str>) {
302        if let Some(idx) = self.prompt.find(CACHE_BOUNDARY) {
303            let cached = &self.prompt[..idx];
304            let dynamic = &self.prompt[idx + CACHE_BOUNDARY.len()..];
305            (
306                if cached.is_empty() {
307                    None
308                } else {
309                    Some(cached)
310                },
311                if dynamic.is_empty() {
312                    None
313                } else {
314                    Some(dynamic)
315                },
316            )
317        } else {
318            if self.prompt.is_empty() {
319                (None, None)
320            } else {
321                (Some(&self.prompt), None)
322            }
323        }
324    }
325}
326
327/// Builder for creating prompt orchestrators
328pub struct PromptBuilder {
329    working_dir: std::path::PathBuf,
330    profile: PromptProfile,
331    sections: Vec<PromptSection>,
332    include_boundary: bool,
333    inject_context: bool,
334}
335
336impl PromptBuilder {
337    pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
338        Self {
339            working_dir: working_dir.into(),
340            profile: PromptProfile::Default,
341            sections: Vec::new(),
342            include_boundary: true,
343            inject_context: true,
344        }
345    }
346
347    pub fn profile(mut self, profile: PromptProfile) -> Self {
348        self.profile = profile;
349        self
350    }
351
352    pub fn add_section(mut self, section: PromptSection) -> Self {
353        self.sections.push(section);
354        self
355    }
356
357    pub fn add_static(self, name: impl Into<String>, content: &'static str) -> Self {
358        self.add_section(PromptSection::static_section(name, content))
359    }
360
361    pub fn add_dynamic<F>(self, name: impl Into<String>, compute: F) -> Self
362    where
363        F: Fn() -> String + Send + Sync + 'static,
364    {
365        self.add_section(PromptSection::dynamic_section(name, compute))
366    }
367
368    pub fn no_boundary(mut self) -> Self {
369        self.include_boundary = false;
370        self
371    }
372
373    pub fn no_context(mut self) -> Self {
374        self.inject_context = false;
375        self
376    }
377
378    pub fn build(self) -> PromptOrchestrator {
379        let mut orchestrator = PromptOrchestrator::new(self.working_dir)
380            .with_profile(self.profile)
381            .with_boundary(self.include_boundary)
382            .with_context_injection(self.inject_context);
383        orchestrator.add_sections(self.sections);
384        orchestrator
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_assemble_simple() {
394        let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
395        orchestrator.add_section(PromptSection::static_section(
396            "identity",
397            "You are an AI assistant.",
398        ));
399
400        let assembled = orchestrator.assemble();
401        assert!(!assembled.prompt.is_empty());
402        assert!(assembled.prompt.contains("identity"));
403        assert!(assembled.cached_sections >= 1);
404    }
405
406    #[test]
407    fn test_assemble_with_dynamic() {
408        let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
409        orchestrator.add_section(PromptSection::static_section("identity", "You are an AI."));
410        orchestrator.add_section(PromptSection::dynamic_section("date", || {
411            format!("Current date: {}", chrono::Local::now().format("%Y-%m-%d"))
412        }));
413
414        let assembled = orchestrator.assemble();
415        assert!(assembled.dynamic_sections >= 1);
416        assert!(assembled.cached_sections >= 1);
417    }
418
419    #[test]
420    fn test_cache_boundary() {
421        let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
422            .with_boundary(true)
423            .with_context_injection(false);
424
425        orchestrator.add_section(PromptSection::static_section("cached", "cached content"));
426        orchestrator.add_section(PromptSection::dynamic_section("dynamic", || {
427            "dynamic content".to_string()
428        }));
429
430        let assembled = orchestrator.assemble();
431        assert!(assembled.prompt.contains(CACHE_BOUNDARY));
432
433        let (cached, dynamic) = assembled.split_at_boundary();
434        assert!(cached.is_some());
435        assert!(dynamic.is_some());
436    }
437
438    #[test]
439    fn test_profile() {
440        let orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
441            .with_profile(PromptProfile::Fast);
442
443        assert_eq!(orchestrator.profile, PromptProfile::Fast);
444    }
445
446    #[test]
447    fn test_builder() {
448        let mut orchestrator = PromptBuilder::new(std::env::current_dir().unwrap())
449            .profile(PromptProfile::Review)
450            .add_static("identity", "You are a code reviewer.")
451            .add_dynamic("date", || "Today".to_string())
452            .build();
453
454        let assembled = orchestrator.assemble();
455        assert!(assembled.prompt.contains("identity"));
456    }
457
458    #[test]
459    fn test_cache_efficiency() {
460        let mut orchestrator =
461            PromptOrchestrator::new(std::env::current_dir().unwrap()).with_context_injection(false);
462
463        // Add static content with words (to have proper token estimate)
464        let static_content = "static content that should be cached properly test test test";
465        orchestrator.add_section(PromptSection::static_section("big", static_content));
466        orchestrator.add_section(PromptSection::dynamic_section("small", || {
467            "dynamic".to_string()
468        }));
469
470        let assembled = orchestrator.assemble();
471        let efficiency = assembled.cache_efficiency();
472
473        // Static content should be cached
474        assert!(efficiency >= 50.0, "Cache efficiency: {}", efficiency);
475    }
476
477    #[test]
478    fn test_invalidate_cache() {
479        let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
480        orchestrator.add_section(PromptSection::static_section("test", "test content"));
481
482        // First assembly
483        let _ = orchestrator.assemble();
484
485        // Invalidate
486        orchestrator.invalidate_cache();
487
488        // Should recalculate
489        let assembled = orchestrator.assemble();
490        assert!(assembled.prompt.contains("test"));
491    }
492}