1use serde_json::{Value, json};
40use talos_core::provider::ToolDefinition;
41
42#[derive(Debug, Clone)]
48pub struct SystemPrompt {
49 full_text: String,
51 cache_control_breakpoints: Vec<usize>,
54}
55
56impl SystemPrompt {
57 #[must_use]
59 pub fn full_text(&self) -> &str {
60 &self.full_text
61 }
62
63 #[must_use]
65 pub fn cache_control_breakpoints(&self) -> &[usize] {
66 &self.cache_control_breakpoints
67 }
68
69 #[must_use]
85 pub fn to_anthropic_format(&self) -> Value {
86 let mut blocks: Vec<Value> = Vec::new();
87 let text = &self.full_text;
88 let mut prev_pos = 0;
89
90 for &bp in &self.cache_control_breakpoints {
91 let end = bp.min(text.len());
92 if end > prev_pos {
93 let section_text = text[prev_pos..end].to_string();
94 blocks.push(json!({
95 "type": "text",
96 "text": section_text,
97 "cache_control": {"type": "ephemeral"}
98 }));
99 }
100 prev_pos = end;
101 }
102
103 if prev_pos < text.len() {
105 let remaining = text[prev_pos..].to_string();
106 blocks.push(json!({
107 "type": "text",
108 "text": remaining
109 }));
110 }
111
112 if blocks.is_empty() {
114 blocks.push(json!({
115 "type": "text",
116 "text": text
117 }));
118 }
119
120 json!({
121 "system": blocks
122 })
123 }
124}
125
126#[derive(Debug)]
131pub struct PromptCache {
132 cache_hits: u64,
134 cache_checks: u64,
136}
137
138impl PromptCache {
139 #[must_use]
141 pub fn new() -> Self {
142 Self {
143 cache_hits: 0,
144 cache_checks: 0,
145 }
146 }
147
148 #[must_use]
171 pub fn build_system_prompt(
172 &self,
173 identity: &str,
174 tools: &[ToolDefinition],
175 context: &str,
176 ) -> SystemPrompt {
177 let mut sorted_tools: Vec<&ToolDefinition> = tools.iter().collect();
179 sorted_tools.sort_by(|a, b| a.name.cmp(&b.name));
180
181 let identity_section = format!("# Identity\n{identity}\n");
183
184 let tools_section = if sorted_tools.is_empty() {
186 String::from("# Tools\nNo tools available.\n")
187 } else {
188 let mut section = String::from("# Tools\n");
189 for tool in &sorted_tools {
190 section.push_str(&tool.to_prompt_text());
191 section.push_str("\n\n");
192 }
193 section
194 };
195
196 let context_section = if context.is_empty() {
198 String::from("# Context\nNo context files loaded.\n")
199 } else {
200 format!("# Context\n{context}\n")
201 };
202
203 let static_prefix = format!("{identity_section}\n{tools_section}\n{context_section}");
205
206 let bp1 = identity_section.len();
208 let bp2 = bp1 + 1 + tools_section.len(); let bp3 = bp2 + 1 + context_section.len(); SystemPrompt {
212 full_text: static_prefix,
213 cache_control_breakpoints: vec![bp1, bp2, bp3],
214 }
215 }
216
217 pub fn track_cache_hit_rate(&mut self, hit: bool) {
223 self.cache_checks += 1;
224 if hit {
225 self.cache_hits += 1;
226 }
227 }
228
229 #[must_use]
233 pub fn cache_hit_rate(&self) -> f64 {
234 if self.cache_checks == 0 {
235 0.0
236 } else {
237 (self.cache_hits as f64 / self.cache_checks as f64) * 100.0
238 }
239 }
240}
241
242impl Default for PromptCache {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248#[cfg(test)]
249#[allow(warnings)]
250mod tests {
251 use super::*;
252
253 #[test]
256 fn test_system_prompt_has_static_prefix() {
257 let cache = PromptCache::new();
258 let tools = vec![ToolDefinition::new(
259 "bash",
260 "Execute shell commands",
261 json!({}),
262 )];
263 let prompt = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
264
265 assert!(prompt.full_text().contains("# Identity"));
266 assert!(prompt.full_text().contains("You are an assistant."));
267 assert!(prompt.full_text().contains("# Tools"));
268 assert!(prompt.full_text().contains("# Context"));
269 assert!(prompt.full_text().contains("Context here."));
270 }
271
272 #[test]
273 fn test_system_prompt_static_prefix_is_consistent() {
274 let cache = PromptCache::new();
275 let tools = vec![ToolDefinition::new(
276 "bash",
277 "Execute shell commands",
278 json!({}),
279 )];
280
281 let prompt1 = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
282 let prompt2 = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
283
284 assert_eq!(prompt1.full_text(), prompt2.full_text());
285 assert_eq!(
286 prompt1.cache_control_breakpoints(),
287 prompt2.cache_control_breakpoints()
288 );
289 }
290
291 #[test]
294 fn test_cache_control_breakpoints_at_correct_positions() {
295 let cache = PromptCache::new();
296 let prompt = cache.build_system_prompt("Identity text.", &[], "");
297
298 let breakpoints = prompt.cache_control_breakpoints();
299 assert_eq!(breakpoints.len(), 3);
300
301 let bp1 = breakpoints[0];
303 assert!(prompt.full_text()[..bp1].contains("# Identity"));
304 assert!(prompt.full_text()[..bp1].contains("Identity text."));
305
306 let bp2 = breakpoints[1];
308 assert!(prompt.full_text()[..bp2].contains("# Tools"));
309
310 let bp3 = breakpoints[2];
312 assert!(prompt.full_text()[..bp3].contains("# Context"));
313
314 assert!(bp3 <= prompt.full_text().len());
316 }
317
318 #[test]
319 fn test_breakpoints_are_increasing() {
320 let cache = PromptCache::new();
321 let tools = vec![ToolDefinition::new("bash", "Execute commands", json!({}))];
322 let prompt = cache.build_system_prompt("Identity.", &tools, "Context.");
323
324 let bps = prompt.cache_control_breakpoints();
325 assert!(bps[0] < bps[1]);
326 assert!(bps[1] < bps[2]);
327 }
328
329 #[test]
332 fn test_tool_definitions_sorted_by_name() {
333 let cache = PromptCache::new();
334 let tools = vec![
335 ToolDefinition::new("write", "Write a file", json!({})),
336 ToolDefinition::new("bash", "Execute commands", json!({})),
337 ToolDefinition::new("read", "Read a file", json!({})),
338 ];
339 let prompt = cache.build_system_prompt("Identity.", &tools, "");
340
341 let text = prompt.full_text();
342 let bash_pos = text.find("## bash").expect("bash should be present");
343 let read_pos = text.find("## read").expect("read should be present");
344 let write_pos = text.find("## write").expect("write should be present");
345
346 assert!(bash_pos < read_pos, "bash should come before read");
347 assert!(read_pos < write_pos, "read should come before write");
348 }
349
350 #[test]
351 fn test_empty_tools_list() {
352 let cache = PromptCache::new();
353 let prompt = cache.build_system_prompt("Identity.", &[], "");
354
355 assert!(prompt.full_text().contains("No tools available."));
356 assert_eq!(prompt.cache_control_breakpoints().len(), 3);
357 }
358
359 #[test]
360 fn test_empty_context() {
361 let cache = PromptCache::new();
362 let prompt = cache.build_system_prompt("Identity.", &[], "");
363
364 assert!(prompt.full_text().contains("No context files loaded."));
365 }
366
367 #[test]
370 fn test_to_anthropic_format_produces_valid_json() {
371 let cache = PromptCache::new();
372 let tools = vec![ToolDefinition::new("bash", "Execute commands", json!({}))];
373 let prompt = cache.build_system_prompt("Identity.", &tools, "Context.");
374 let anthropic = prompt.to_anthropic_format();
375
376 assert!(anthropic.get("system").is_some());
378 let system_blocks = anthropic["system"]
379 .as_array()
380 .expect("operation should succeed");
381 assert!(!system_blocks.is_empty());
382 }
383
384 #[test]
385 fn test_to_anthropic_format_has_cache_control_markers() {
386 let cache = PromptCache::new();
387 let prompt = cache.build_system_prompt("Identity.", &[], "");
388 let anthropic = prompt.to_anthropic_format();
389
390 let system_blocks = anthropic["system"]
391 .as_array()
392 .expect("operation should succeed");
393
394 let cached_blocks: Vec<_> = system_blocks
397 .iter()
398 .filter(|b| b.get("cache_control").is_some())
399 .collect();
400
401 assert!(
402 cached_blocks.len() >= 3,
403 "Expected at least 3 cached blocks, got {}",
404 cached_blocks.len()
405 );
406
407 for block in &cached_blocks {
409 let cc = block["cache_control"]
410 .as_object()
411 .expect("operation should succeed");
412 assert_eq!(
413 cc.get("type")
414 .expect("operation should succeed")
415 .as_str()
416 .expect("operation should succeed"),
417 "ephemeral"
418 );
419 }
420 }
421
422 #[test]
423 fn test_to_anthropic_format_last_block_uncached() {
424 let cache = PromptCache::new();
425 let prompt = cache.build_system_prompt("Identity.", &[], "Context.");
426 let anthropic = prompt.to_anthropic_format();
427
428 let system_blocks = anthropic["system"]
429 .as_array()
430 .expect("operation should succeed");
431
432 let last_block = system_blocks.last().expect("operation should succeed");
437 assert!(last_block.get("cache_control").is_some());
439 }
440
441 #[test]
442 fn test_to_anthropic_format_with_empty_prompt() {
443 let prompt = SystemPrompt {
444 full_text: String::new(),
445 cache_control_breakpoints: vec![],
446 };
447 let anthropic = prompt.to_anthropic_format();
448
449 let system_blocks = anthropic["system"]
450 .as_array()
451 .expect("operation should succeed");
452 assert_eq!(system_blocks.len(), 1);
453 assert_eq!(system_blocks[0]["text"], "");
454 assert!(system_blocks[0].get("cache_control").is_none());
456 }
457
458 #[test]
461 fn test_cache_hit_rate_initially_zero() {
462 let cache = PromptCache::new();
463 assert_eq!(cache.cache_hit_rate(), 0.0);
464 }
465
466 #[test]
467 fn test_cache_hit_rate_after_hits() {
468 let mut cache = PromptCache::new();
469 cache.track_cache_hit_rate(true);
470 cache.track_cache_hit_rate(true);
471 cache.track_cache_hit_rate(false);
472 cache.track_cache_hit_rate(true);
473
474 assert!((cache.cache_hit_rate() - 75.0).abs() < f64::EPSILON);
476 }
477
478 #[test]
479 fn test_cache_hit_rate_all_hits() {
480 let mut cache = PromptCache::new();
481 cache.track_cache_hit_rate(true);
482 cache.track_cache_hit_rate(true);
483
484 assert!((cache.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
485 }
486
487 #[test]
488 fn test_cache_hit_rate_all_misses() {
489 let mut cache = PromptCache::new();
490 cache.track_cache_hit_rate(false);
491 cache.track_cache_hit_rate(false);
492
493 assert!((cache.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
494 }
495
496 #[test]
497 fn test_cache_hit_rate_single_hit() {
498 let mut cache = PromptCache::new();
499 cache.track_cache_hit_rate(true);
500
501 assert!((cache.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
502 }
503
504 #[test]
505 fn test_cache_hit_rate_single_miss() {
506 let mut cache = PromptCache::new();
507 cache.track_cache_hit_rate(false);
508
509 assert!((cache.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
510 }
511
512 #[test]
515 fn test_tool_definition_to_prompt_text() {
516 let tool = ToolDefinition::new(
517 "read_file",
518 "Read the contents of a file",
519 json!({
520 "type": "object",
521 "properties": {
522 "path": {"type": "string"}
523 }
524 }),
525 );
526
527 let text = tool.to_prompt_text();
528 assert!(text.contains("## read_file"));
529 assert!(text.contains("Read the contents of a file"));
530 assert!(text.contains("path"));
531 }
532
533 #[test]
536 fn test_full_prompt_with_all_sections() {
537 let cache = PromptCache::new();
538 let tools = vec![
539 ToolDefinition::new("bash", "Run shell commands", json!({"command": "string"})),
540 ToolDefinition::new("read", "Read files", json!({"path": "string"})),
541 ToolDefinition::new(
542 "write",
543 "Write files",
544 json!({"path": "string", "content": "string"}),
545 ),
546 ];
547 let identity = "You are Talos, a safety-first agent runtime.";
548 let context = "# AGENTS.md\nFollow the coding guide.";
549
550 let prompt = cache.build_system_prompt(identity, &tools, context);
551 let anthropic = prompt.to_anthropic_format();
552
553 assert!(prompt.full_text().contains("You are Talos"));
555 assert!(prompt.full_text().contains("## bash"));
556 assert!(prompt.full_text().contains("## read"));
557 assert!(prompt.full_text().contains("## write"));
558 assert!(prompt.full_text().contains("AGENTS.md"));
559
560 let system_blocks = anthropic["system"]
562 .as_array()
563 .expect("operation should succeed");
564 assert!(!system_blocks.is_empty());
565
566 let text = prompt.full_text();
568 assert!(
569 text.find("## bash").expect("operation should succeed")
570 < text.find("## read").expect("operation should succeed")
571 );
572 assert!(
573 text.find("## read").expect("operation should succeed")
574 < text.find("## write").expect("operation should succeed")
575 );
576 }
577
578 #[test]
579 fn test_prompt_cache_default_trait() {
580 let cache = PromptCache::default();
581 assert_eq!(cache.cache_hit_rate(), 0.0);
582 }
583
584 #[test]
585 fn test_system_prompt_clone() {
586 let cache = PromptCache::new();
587 let prompt = cache.build_system_prompt("Identity.", &[], "");
588 let cloned = prompt.clone();
589
590 assert_eq!(prompt.full_text(), cloned.full_text());
591 assert_eq!(
592 prompt.cache_control_breakpoints(),
593 cloned.cache_control_breakpoints()
594 );
595 }
596
597 #[test]
598 fn test_tool_definition_clone_and_eq() {
599 let tool1 = ToolDefinition::new("bash", "Run commands", json!({}));
600 let tool2 = tool1.clone();
601
602 assert_eq!(tool1, tool2);
603 }
604}