1use std::sync::Arc;
8
9use uuid::Uuid;
10
11use behest_core::cache::{CacheControl, CacheControlKind};
12use behest_core::token::estimate_message_tokens;
13
14use super::token::estimate_records_tokens;
15use behest_context::{ContextAdapter, ContextFactory, ContextInput, ContextOutput};
16use behest_provider::{ChatRequest, ContentPart, Message, ModelName, ToolSpec};
17use behest_store::MessageRecord;
18
19use super::error::RuntimeResult;
20use super::policy::PromptCacheConfig;
21
22pub struct ContextPipeline {
31 factory: ContextFactory,
32 max_history_messages: usize,
33 max_history_tokens: usize,
34 enable_compaction_filter: bool,
35 cache_strategy: PromptCacheConfig,
36}
37
38impl ContextPipeline {
39 #[must_use]
41 pub fn new() -> Self {
42 Self {
43 factory: ContextFactory::new(),
44 max_history_messages: 50,
45 max_history_tokens: 64_000,
46 enable_compaction_filter: true,
47 cache_strategy: PromptCacheConfig::default(),
48 }
49 }
50
51 #[must_use]
53 pub fn with_factory(factory: ContextFactory) -> Self {
54 Self {
55 factory,
56 max_history_messages: 50,
57 max_history_tokens: 64_000,
58 enable_compaction_filter: true,
59 cache_strategy: PromptCacheConfig::default(),
60 }
61 }
62
63 #[must_use]
67 pub fn with_max_history(mut self, max: usize) -> Self {
68 self.max_history_messages = max;
69 self
70 }
71
72 #[must_use]
77 pub fn with_max_history_tokens(mut self, max: usize) -> Self {
78 self.max_history_tokens = max;
79 self
80 }
81
82 #[must_use]
87 pub fn with_compaction_filter(mut self, enable: bool) -> Self {
88 self.enable_compaction_filter = enable;
89 self
90 }
91
92 #[must_use]
104 pub fn with_cache_strategy(mut self, config: PromptCacheConfig) -> Self {
105 self.cache_strategy = config;
106 self
107 }
108
109 pub fn register<A>(&mut self, adapter: A)
111 where
112 A: ContextAdapter + 'static,
113 {
114 self.factory.register(adapter);
115 }
116
117 pub fn register_arc(&mut self, adapter: Arc<dyn ContextAdapter>) {
119 self.factory.register_arc(adapter);
120 }
121
122 pub fn adapter_names(&self) -> impl Iterator<Item = &str> {
124 self.factory.adapter_names()
125 }
126
127 pub async fn build(
138 &self,
139 store: &super::store::RuntimeStore,
140 session_id: Uuid,
141 model: ModelName,
142 user_message: Option<&str>,
143 tools: Option<&[ToolSpec]>,
144 ) -> RuntimeResult<ChatRequest> {
145 let input = ContextInput {
146 user_message: user_message.map(str::to_owned),
147 session_id: Some(session_id.to_string()),
148 metadata: serde_json::Value::Null,
149 };
150
151 let mut output = self.factory.build(&input).await.map_err(|e| {
152 super::error::RuntimeError::Context(behest_core::error::ContextError::AdapterFailed {
153 adapter: "pipeline".to_owned(),
154 message: e.to_string(),
155 })
156 })?;
157
158 let records = store
159 .sessions()
160 .list_messages(&session_id)
161 .await
162 .map_err(super::error::RuntimeError::Storage)?;
163
164 let records = if self.enable_compaction_filter {
165 apply_compaction_filter(records)
166 } else {
167 records
168 };
169
170 let records = trim_by_tokens(records, self.max_history_tokens);
171
172 let history: Vec<Message> = records
173 .into_iter()
174 .filter_map(super::store::record_to_message)
175 .collect();
176
177 output.extend(history);
178
179 if let Some(text) = user_message {
180 output.extend([Message::user_text(text)]);
181 }
182
183 if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
184 apply_message_cache_breakpoints(
185 output.messages_mut(),
186 &self.cache_strategy,
187 is_system_token_count,
188 );
189 }
190
191 let request = match tools {
192 Some(specs) => {
193 let mut specs = specs.to_vec();
194 if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
195 apply_tool_cache_breakpoints(&mut specs, &self.cache_strategy);
196 }
197 output.into_request_with_tools(model, &specs)
198 }
199 None => output.into_request(model),
200 };
201
202 Ok(request)
203 }
204
205 pub async fn build_context(
216 &self,
217 store: &super::store::RuntimeStore,
218 session_id: Uuid,
219 user_message: Option<&str>,
220 ) -> RuntimeResult<ContextOutput> {
221 let input = ContextInput {
222 user_message: user_message.map(str::to_owned),
223 session_id: Some(session_id.to_string()),
224 metadata: serde_json::Value::Null,
225 };
226
227 let mut output = self.factory.build(&input).await.map_err(|e| {
228 super::error::RuntimeError::Context(behest_core::error::ContextError::AdapterFailed {
229 adapter: "pipeline".to_owned(),
230 message: e.to_string(),
231 })
232 })?;
233
234 let records = store
235 .sessions()
236 .list_messages(&session_id)
237 .await
238 .map_err(super::error::RuntimeError::Storage)?;
239
240 let records = if self.enable_compaction_filter {
241 apply_compaction_filter(records)
242 } else {
243 records
244 };
245
246 let records = trim_by_tokens(records, self.max_history_tokens);
247
248 let history: Vec<Message> = records
249 .into_iter()
250 .filter_map(super::store::record_to_message)
251 .collect();
252
253 output.extend(history);
254
255 if let Some(text) = user_message {
256 output.extend([Message::user_text(text)]);
257 }
258
259 if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
260 apply_message_cache_breakpoints(
261 output.messages_mut(),
262 &self.cache_strategy,
263 is_system_token_count,
264 );
265 }
266
267 Ok(output)
268 }
269}
270
271impl Default for ContextPipeline {
272 fn default() -> Self {
273 Self::new()
274 }
275}
276
277fn is_system_token_count(msg: &Message, min_tokens: usize) -> bool {
287 if !matches!(msg, Message::System { .. }) {
288 return false;
289 }
290 estimate_message_tokens(msg) >= min_tokens
291}
292
293fn apply_message_cache_breakpoints(
302 messages: &mut [Message],
303 config: &PromptCacheConfig,
304 system_check: fn(&Message, usize) -> bool,
305) {
306 let ctrl = CacheControl {
307 kind: CacheControlKind::Ephemeral,
308 ttl: config.default_ttl,
309 };
310
311 if let Some(last_system_idx) = messages
313 .iter()
314 .rposition(|m| matches!(m, Message::System { .. }))
315 {
316 let qualifies = system_check(&messages[last_system_idx], config.min_system_tokens);
317 if qualifies
318 && let Some(last_part) = last_content_part_mut(&mut messages[last_system_idx])
319 && last_part.cache_control().is_none()
320 {
321 last_part.set_cache_control(ctrl);
322 }
323 }
324
325 if messages.len() >= 2
328 && let Some(tail_idx) = messages
329 .iter()
330 .rposition(|m| !matches!(m, Message::User { .. }))
331 && tail_idx < messages.len() - 1
332 && let Some(last_part) = last_content_part_mut(&mut messages[tail_idx])
333 && last_part.cache_control().is_none()
334 {
335 last_part.set_cache_control(ctrl);
336 }
337}
338
339fn apply_tool_cache_breakpoints(specs: &mut [ToolSpec], config: &PromptCacheConfig) {
341 let ctrl = CacheControl {
342 kind: CacheControlKind::Ephemeral,
343 ttl: config.default_ttl,
344 };
345 for spec in specs.iter_mut() {
346 if spec.cache_control.is_none() {
347 spec.cache_control = Some(ctrl);
348 }
349 }
350}
351
352fn last_content_part_mut(msg: &mut Message) -> Option<&mut ContentPart> {
355 let content = match msg {
356 Message::System { content } | Message::User { content } | Message::Tool { content, .. } => {
357 content
358 }
359 Message::Assistant { content, .. } => content,
360 _ => return None,
361 };
362 content.last_mut()
363}
364
365fn apply_compaction_filter(records: Vec<MessageRecord>) -> Vec<MessageRecord> {
369 if records.is_empty() {
370 return records;
371 }
372
373 let mut summary_idx: Option<usize> = None;
377 let mut compaction_idx: Option<usize> = None;
378
379 for i in (0..records.len()).rev() {
380 let rec = &records[i];
381 if rec.is_summary && summary_idx.is_none() {
382 summary_idx = Some(i);
383 } else if rec.is_compaction && compaction_idx.is_none() && summary_idx.is_some() {
384 compaction_idx = Some(i);
386 break;
387 } else if !rec.is_summary && !rec.is_compaction && summary_idx.is_some() {
388 summary_idx = None;
391 }
392 }
393
394 let (Some(c_idx), Some(s_idx)) = (compaction_idx, summary_idx) else {
395 return records;
396 };
397
398 let tail_start_id = records[c_idx]
399 .compaction_meta
400 .as_ref()
401 .and_then(|m| m.tail_start_id);
402
403 let Some(tail_start) = tail_start_id else {
404 return records;
405 };
406
407 let tail_idx = records.iter().position(|r| r.id == tail_start);
409
410 let compact_end = records
417 .iter()
418 .skip(s_idx + 1)
419 .position(|r| !r.is_compaction && !r.is_summary)
420 .map_or(records.len(), |p| s_idx + 1 + p);
421
422 let mut result = Vec::with_capacity(records.len());
428
429 if let Some(summary_meta) = &records[s_idx].compaction_meta
432 && let Some(summary_text) = &summary_meta.summary_text
433 {
434 let checkpoint = MessageRecord {
435 id: Uuid::now_v7(),
436 session_id: records[c_idx].session_id,
437 role: behest_store::MessageRole::System,
438 content: vec![ContentPart::text(format!(
439 "<conversation-checkpoint>\n<summary>\n{summary_text}\n</summary>\n</conversation-checkpoint>"
440 ))],
441 tool_calls: Vec::new(),
442 tool_call_id: None,
443 tool_name: None,
444 usage: None,
445 created_at: records[s_idx].created_at,
446 is_compaction: false,
447 is_summary: false,
448 compaction_meta: None,
449 };
450 result.push(checkpoint);
451 }
452
453 if let Some(ti) = tail_idx {
455 let tail_end = c_idx.min(records.len());
456 for rec in records.iter().skip(ti).take(tail_end.saturating_sub(ti)) {
457 if !rec.is_compaction && !rec.is_summary {
458 result.push(rec.clone());
459 }
460 }
461 }
462
463 for rec in records.iter().skip(compact_end) {
465 result.push(rec.clone());
466 }
467
468 result
469}
470
471fn trim_by_tokens(records: Vec<MessageRecord>, max_tokens: usize) -> Vec<MessageRecord> {
478 if records.is_empty() {
479 return records;
480 }
481
482 let total = estimate_records_tokens(&records);
483 if total <= max_tokens {
484 return records;
485 }
486
487 let mut kept = Vec::new();
493 let mut tokens = 0usize;
494
495 for rec in records.into_iter().rev() {
496 let rec_tokens = super::token::estimate_record_tokens(&rec);
497 if tokens + rec_tokens > max_tokens && !kept.is_empty() {
498 break;
501 }
502 tokens += rec_tokens;
503 kept.push(rec);
504 }
505
506 kept.reverse();
507
508 kept
512}
513
514#[cfg(test)]
515#[allow(clippy::unwrap_used)]
516mod tests {
517 use super::*;
518 use crate::memory::MemoryRunStore;
519 use behest_context::StaticAdapter;
520 use behest_provider::ContentPart;
521 use behest_store::memory::{MemoryExecutionStore, MemorySessionStore};
522 use behest_store::{CompactionMeta, MessageRole, Session};
523
524 fn make_store() -> super::super::store::RuntimeStore {
525 let sessions = MemorySessionStore::new();
526 let executions = MemoryExecutionStore::new();
527 let runs = MemoryRunStore::new();
528 super::super::store::RuntimeStore::new(
529 Box::new(sessions),
530 Box::new(executions),
531 Box::new(runs),
532 )
533 }
534
535 fn make_record_for(session_id: Uuid, role: MessageRole, text: &str) -> MessageRecord {
536 MessageRecord::new(session_id, role, vec![ContentPart::text(text)])
537 }
538
539 #[tokio::test]
540 async fn pipeline_should_compose_system_and_history() {
541 let store = make_store();
542
543 let session = Session::new("Test", ModelName::new("gpt-4"));
544 store
545 .sessions()
546 .create_session(session.clone())
547 .await
548 .unwrap();
549
550 let user_msg = make_record_for(session.id, MessageRole::User, "Hello");
551 store.sessions().append_message(user_msg).await.unwrap();
552
553 let mut pipeline = ContextPipeline::new();
554 pipeline.register(StaticAdapter::system("You are helpful."));
555
556 let request = pipeline
557 .build(
558 &store,
559 session.id,
560 ModelName::new("gpt-4"),
561 Some("How are you?"),
562 None,
563 )
564 .await
565 .unwrap();
566
567 assert_eq!(request.messages.len(), 3);
568 assert!(matches!(request.messages[0], Message::System { .. }));
569 assert!(matches!(request.messages[1], Message::User { .. }));
570 assert!(matches!(request.messages[2], Message::User { .. }));
571 }
572
573 #[tokio::test]
574 async fn pipeline_should_apply_token_trim() {
575 let store = make_store();
576
577 let session = Session::new("Test", ModelName::new("gpt-4"));
578 store
579 .sessions()
580 .create_session(session.clone())
581 .await
582 .unwrap();
583
584 for i in 0..10 {
585 let msg = make_record_for(session.id, MessageRole::User, &format!("Message {i}"));
586 store.sessions().append_message(msg).await.unwrap();
587 }
588
589 let pipeline = ContextPipeline::new().with_max_history_tokens(50);
591
592 let request = pipeline
593 .build(&store, session.id, ModelName::new("gpt-4"), None, None)
594 .await
595 .unwrap();
596
597 assert!(request.messages.len() < 10);
599 }
600
601 #[tokio::test]
602 async fn pipeline_should_filter_compacted_head() {
603 let store = make_store();
604
605 let session = Session::new("Test", ModelName::new("gpt-4"));
606 store
607 .sessions()
608 .create_session(session.clone())
609 .await
610 .unwrap();
611
612 let sid = session.id;
613
614 let m1 = make_record_for(sid, MessageRole::User, "m1");
616 let m2 = make_record_for(sid, MessageRole::Assistant, "m2");
617 let m3 = make_record_for(sid, MessageRole::User, "m3");
618 let m4 = make_record_for(sid, MessageRole::Assistant, "m4");
619
620 let m3_id = m3.id;
621
622 store.sessions().append_message(m1).await.unwrap();
623 store.sessions().append_message(m2).await.unwrap();
624 store.sessions().append_message(m3).await.unwrap();
625 store.sessions().append_message(m4).await.unwrap();
626
627 let compaction_user = MessageRecord {
629 id: Uuid::now_v7(),
630 session_id: sid,
631 role: MessageRole::User,
632 content: vec![ContentPart::text("[compaction]")],
633 tool_calls: Vec::new(),
634 tool_call_id: None,
635 tool_name: None,
636 usage: None,
637 created_at: chrono::Utc::now(),
638 is_compaction: true,
639 is_summary: false,
640 compaction_meta: Some(CompactionMeta::new(m3_id)),
641 };
642 store
643 .sessions()
644 .append_message(compaction_user)
645 .await
646 .unwrap();
647
648 let summary_msg = MessageRecord {
649 id: Uuid::now_v7(),
650 session_id: sid,
651 role: MessageRole::Assistant,
652 content: vec![ContentPart::text("Summary of m1-m2")],
653 tool_calls: Vec::new(),
654 tool_call_id: None,
655 tool_name: None,
656 usage: None,
657 created_at: chrono::Utc::now(),
658 is_compaction: false,
659 is_summary: true,
660 compaction_meta: Some(
661 CompactionMeta::new(m3_id).with_summary("Summary of m1-m2".to_owned()),
662 ),
663 };
664 store.sessions().append_message(summary_msg).await.unwrap();
665
666 let m5 = make_record_for(sid, MessageRole::User, "m5");
668 store.sessions().append_message(m5).await.unwrap();
669
670 let pipeline = ContextPipeline::new();
671
672 let request = pipeline
673 .build(&store, sid, ModelName::new("gpt-4"), None, None)
674 .await
675 .unwrap();
676
677 let has_m1 = request.messages.iter().any(|m| {
680 matches!(m, Message::User { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text == "m1")))
681 });
682 let has_m3 = request.messages.iter().any(|m| {
683 matches!(m, Message::User { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text == "m3")))
684 });
685 let has_checkpoint = request.messages.iter().any(|m| {
686 matches!(m, Message::System { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text.contains("conversation-checkpoint"))))
687 });
688
689 assert!(!has_m1, "compacted head should be excluded");
690 assert!(has_m3, "retained tail should be included");
691 assert!(has_checkpoint, "compaction checkpoint should be present");
692 }
693
694 #[tokio::test]
695 async fn pipeline_no_compaction_returns_all() {
696 let store = make_store();
697
698 let session = Session::new("Test", ModelName::new("gpt-4"));
699 store
700 .sessions()
701 .create_session(session.clone())
702 .await
703 .unwrap();
704
705 for i in 0..5 {
706 let msg = make_record_for(session.id, MessageRole::User, &format!("msg{i}"));
707 store.sessions().append_message(msg).await.unwrap();
708 }
709
710 let pipeline = ContextPipeline::new().with_compaction_filter(true);
711
712 let request = pipeline
713 .build(&store, session.id, ModelName::new("gpt-4"), None, None)
714 .await
715 .unwrap();
716
717 assert_eq!(request.messages.len(), 5);
718 }
719
720 #[test]
721 fn trim_by_tokens_preserves_tail() {
722 let sid = Uuid::now_v7();
723 let records: Vec<MessageRecord> = (0..20)
724 .map(|i| make_record_for(sid, MessageRole::User, &format!("msg{i:03}")))
725 .collect();
726
727 let trimmed = trim_by_tokens(records, 100);
728
729 assert!(!trimmed.is_empty());
731 assert!(trimmed.len() < 20);
732 }
733
734 #[test]
735 fn trim_by_tokens_all_when_under_budget() {
736 let sid = Uuid::now_v7();
737 let records = vec![
738 make_record_for(sid, MessageRole::User, "hi"),
739 make_record_for(sid, MessageRole::Assistant, "hello"),
740 ];
741
742 let trimmed = trim_by_tokens(records.clone(), 1000);
743 assert_eq!(trimmed.len(), 2);
744 }
745
746 #[test]
747 fn filter_compacted_no_compaction_returns_unchanged() {
748 let sid = Uuid::now_v7();
749 let records = vec![
750 make_record_for(sid, MessageRole::User, "a"),
751 make_record_for(sid, MessageRole::Assistant, "b"),
752 ];
753
754 let filtered = apply_compaction_filter(records.clone());
755 assert_eq!(filtered.len(), 2);
756 }
757
758 #[tokio::test]
759 async fn pipeline_should_place_system_cache_breakpoint() {
760 let store = make_store();
761 let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
762 store
763 .sessions()
764 .create_session(session.clone())
765 .await
766 .unwrap();
767
768 let long_prompt = "x".repeat(2000);
771 let mut pipeline = ContextPipeline::new()
772 .with_cache_strategy(PromptCacheConfig::new().with_min_system_tokens(10));
773 pipeline.register(StaticAdapter::system(long_prompt));
774
775 let request = pipeline
776 .build(
777 &store,
778 session.id,
779 ModelName::new("claude-3-sonnet"),
780 Some("Hello"),
781 None,
782 )
783 .await
784 .unwrap();
785
786 if let Message::System { content } = &request.messages[0] {
788 assert!(
789 content.last().unwrap().cache_control().is_some(),
790 "system-end cache breakpoint should be set"
791 );
792 } else {
793 panic!("expected first message to be system");
794 }
795 }
796
797 #[tokio::test]
798 async fn pipeline_should_skip_cache_breakpoint_when_disabled() {
799 let store = make_store();
800 let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
801 store
802 .sessions()
803 .create_session(session.clone())
804 .await
805 .unwrap();
806
807 let long_prompt = "x".repeat(2000);
808 let mut pipeline =
809 ContextPipeline::new().with_cache_strategy(PromptCacheConfig::disabled());
810 pipeline.register(StaticAdapter::system(long_prompt));
811
812 let request = pipeline
813 .build(
814 &store,
815 session.id,
816 ModelName::new("claude-3-sonnet"),
817 Some("Hello"),
818 None,
819 )
820 .await
821 .unwrap();
822
823 if let Message::System { content } = &request.messages[0] {
824 assert!(
825 content.last().unwrap().cache_control().is_none(),
826 "cache breakpoint should not be set when disabled"
827 );
828 } else {
829 panic!("expected first message to be system");
830 }
831 }
832
833 #[tokio::test]
834 async fn pipeline_should_place_tool_cache_breakpoint() {
835 let store = make_store();
836 let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
837 store
838 .sessions()
839 .create_session(session.clone())
840 .await
841 .unwrap();
842
843 let pipeline = ContextPipeline::new();
844 let tools = vec![ToolSpec::new(
845 "echo",
846 "Echo",
847 serde_json::json!({"type": "object"}),
848 )];
849
850 let request = pipeline
851 .build(
852 &store,
853 session.id,
854 ModelName::new("claude-3-sonnet"),
855 Some("Hello"),
856 Some(&tools),
857 )
858 .await
859 .unwrap();
860
861 assert_eq!(request.tools.len(), 1);
862 assert!(
863 request.tools[0].cache_control.is_some(),
864 "tool cache marker should be set"
865 );
866 }
867}