Skip to main content

orchestral_runtime/context/
mod.rs

1//! # Orchestral Context
2//!
3//! Context abstraction and assembly layer for Orchestral.
4//! Provides a unified view over events and artifacts (text/image/file/etc.).
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use thiserror::Error;
14
15use orchestral_core::store::{Event, EventStore, StoreError};
16
17/// A single context slice included in a context window
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ContextSlice {
20    pub role: String,
21    pub content: String,
22    #[serde(default)]
23    pub attachments: Vec<String>,
24    #[serde(default)]
25    pub weight: f32,
26    #[serde(default)]
27    pub metadata: HashMap<String, Value>,
28    #[serde(default)]
29    pub timestamp: Option<DateTime<Utc>>,
30}
31
32/// Token budget for context assembly
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TokenBudget {
35    pub max_tokens: usize,
36    pub used_tokens: usize,
37    /// Characters per token estimate. Varies by backend and language mix.
38    /// English/code ~3.5, Chinese ~1.8, mixed ~2.5. Default: 3.0.
39    #[serde(default = "default_chars_per_token")]
40    pub chars_per_token: f32,
41    /// Max tokens for a single tool output before truncation. Default: 2000.
42    #[serde(default = "default_max_tool_output_tokens")]
43    pub max_tool_output_tokens: usize,
44}
45
46fn default_chars_per_token() -> f32 {
47    3.0
48}
49fn default_max_tool_output_tokens() -> usize {
50    2000
51}
52
53impl TokenBudget {
54    pub fn new(max_tokens: usize) -> Self {
55        Self {
56            max_tokens,
57            used_tokens: 0,
58            chars_per_token: default_chars_per_token(),
59            max_tool_output_tokens: default_max_tool_output_tokens(),
60        }
61    }
62
63    pub fn with_chars_per_token(mut self, chars_per_token: f32) -> Self {
64        self.chars_per_token = chars_per_token.max(0.5);
65        self
66    }
67
68    pub fn remaining(&self) -> usize {
69        self.max_tokens.saturating_sub(self.used_tokens)
70    }
71
72    /// Estimate tokens for a string using the configured chars_per_token ratio.
73    pub fn estimate_tokens(&self, text: &str) -> usize {
74        let chars = text.chars().count();
75        std::cmp::max(1, (chars as f32 / self.chars_per_token).ceil() as usize)
76    }
77}
78
79impl Default for TokenBudget {
80    fn default() -> Self {
81        Self::new(4096)
82    }
83}
84
85/// Assembled context window
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ContextWindow {
88    pub core: Vec<ContextSlice>,
89    pub optional: Vec<ContextSlice>,
90    pub deferred: Vec<ContextSlice>,
91    pub budget: TokenBudget,
92}
93
94impl ContextWindow {
95    pub fn new(budget: TokenBudget) -> Self {
96        Self {
97            core: Vec::new(),
98            optional: Vec::new(),
99            deferred: Vec::new(),
100            budget,
101        }
102    }
103}
104
105/// Context request input
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ContextRequest {
108    pub thread_id: String,
109    #[serde(default)]
110    pub task_id: Option<String>,
111    #[serde(default)]
112    pub interaction_id: Option<String>,
113    #[serde(default)]
114    pub query: Option<String>,
115    #[serde(default)]
116    pub budget: TokenBudget,
117    #[serde(default)]
118    pub include_history: bool,
119    #[serde(default)]
120    pub tags: Vec<String>,
121}
122
123impl ContextRequest {
124    pub fn new(thread_id: impl Into<String>) -> Self {
125        Self {
126            thread_id: thread_id.into(),
127            task_id: None,
128            interaction_id: None,
129            query: None,
130            budget: TokenBudget::default(),
131            include_history: true,
132            tags: Vec::new(),
133        }
134    }
135}
136
137/// Context builder configuration
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ContextBuilderConfig {
140    /// Max events to include (0 = all)
141    pub history_limit: usize,
142}
143
144impl Default for ContextBuilderConfig {
145    fn default() -> Self {
146        Self { history_limit: 50 }
147    }
148}
149
150/// Context builder errors
151#[derive(Debug, Error)]
152pub enum ContextError {
153    #[error("store error: {0}")]
154    Store(#[from] StoreError),
155    #[error("invalid request: {0}")]
156    InvalidRequest(String),
157}
158
159/// Thread-level summary artifact.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ThreadSummary {
162    pub thread_id: String,
163    pub summary_text: String,
164    #[serde(default)]
165    pub model: Option<String>,
166    #[serde(default)]
167    pub version: Option<String>,
168    pub last_event_id: i64,
169    #[serde(default)]
170    pub metadata: HashMap<String, Value>,
171    pub updated_at: DateTime<Utc>,
172}
173
174/// Pluggable thread summary strategy.
175#[async_trait]
176pub trait ThreadSummarizer: Send + Sync {
177    async fn summarize_thread(
178        &self,
179        request: &ContextRequest,
180    ) -> Result<ThreadSummary, ContextError>;
181}
182
183/// Context builder trait
184#[async_trait]
185pub trait ContextBuilder: Send + Sync {
186    async fn build(&self, request: &ContextRequest) -> Result<ContextWindow, ContextError>;
187}
188
189/// Basic context builder (rule-based, no LLM summarization)
190pub struct BasicContextBuilder {
191    event_store: Arc<dyn EventStore>,
192    config: ContextBuilderConfig,
193}
194
195impl BasicContextBuilder {
196    pub fn new(event_store: Arc<dyn EventStore>) -> Self {
197        Self {
198            event_store,
199            config: ContextBuilderConfig::default(),
200        }
201    }
202
203    pub fn with_config(event_store: Arc<dyn EventStore>, config: ContextBuilderConfig) -> Self {
204        Self {
205            event_store,
206            config,
207        }
208    }
209}
210
211#[async_trait]
212impl ContextBuilder for BasicContextBuilder {
213    async fn build(&self, request: &ContextRequest) -> Result<ContextWindow, ContextError> {
214        if request.thread_id.is_empty() {
215            return Err(ContextError::InvalidRequest(
216                "thread_id must not be empty".to_string(),
217            ));
218        }
219
220        let mut window = ContextWindow::new(request.budget.clone());
221
222        if request.include_history {
223            let mut events = if self.config.history_limit == 0 {
224                self.event_store.query_by_thread(&request.thread_id).await?
225            } else {
226                self.event_store
227                    .query_by_thread_with_limit(&request.thread_id, self.config.history_limit)
228                    .await?
229            };
230
231            events.sort_by_key(|a| a.timestamp());
232            let mut slices: Vec<ContextSlice> = events.iter().filter_map(event_to_slice).collect();
233
234            // Layer 1: Truncate large individual tool outputs.
235            let max_tool_tokens = request.budget.max_tool_output_tokens;
236            let cpt = request.budget.chars_per_token;
237            for slice in &mut slices {
238                if slice.role == "system" {
239                    slice.content = truncate_tool_output(&slice.content, max_tool_tokens, cpt);
240                }
241            }
242
243            // Layer 2: FIFO eviction of old system/tool slices when over budget.
244            // Keep first 2 + last N slices intact, replace old system slices with traces.
245            let total_tokens: usize = slices
246                .iter()
247                .map(|s| estimate_slice_tokens(s, &request.budget))
248                .sum();
249            if total_tokens > request.budget.max_tokens && slices.len() > 4 {
250                let head_keep = 2.min(slices.len());
251                let tail_keep = (slices.len() / 3).max(2).min(slices.len() - head_keep);
252                let middle_start = head_keep;
253                let middle_end = slices.len() - tail_keep;
254
255                for slice in slices.iter_mut().take(middle_end).skip(middle_start) {
256                    if slice.role == "system" {
257                        let trace = compact_system_trace(&slice.content);
258                        slice.content = trace;
259                        slice.weight = 0.1;
260                    } else if slice.role == "assistant" && slice.content.chars().count() > 200 {
261                        let chars: String = slice.content.chars().take(120).collect();
262                        slice.content = format!("{}... (truncated)", chars);
263                        slice.weight = 0.3;
264                    }
265                }
266            }
267
268            // Push slices with budget enforcement.
269            for slice in slices {
270                push_slice_with_budget(&mut window, slice, SliceBucket::Core);
271            }
272        }
273
274        Ok(window)
275    }
276}
277
278/// Compress a system trace/tool output to a one-line summary for FIFO eviction.
279fn compact_system_trace(content: &str) -> String {
280    let first_line = content.lines().next().unwrap_or(content);
281    let total_chars = content.chars().count();
282    if total_chars <= 80 {
283        return content.to_string();
284    }
285    let prefix: String = first_line.chars().take(60).collect();
286    format!("[{}... ({} chars)]", prefix, total_chars)
287}
288
289fn event_to_slice(event: &Event) -> Option<ContextSlice> {
290    let timestamp = Some(event.timestamp());
291    match event {
292        Event::UserInput { payload, .. } => Some(ContextSlice {
293            role: "user".to_string(),
294            content: payload_to_string(payload),
295            attachments: Vec::new(),
296            weight: 1.0,
297            metadata: HashMap::new(),
298            timestamp,
299        }),
300        Event::AssistantOutput { payload, .. } => Some(ContextSlice {
301            role: "assistant".to_string(),
302            content: payload_to_string(payload),
303            attachments: Vec::new(),
304            weight: 1.0,
305            metadata: HashMap::new(),
306            timestamp,
307        }),
308        Event::ExternalEvent { kind, payload, .. } => Some(ContextSlice {
309            role: "system".to_string(),
310            content: format!("external:{} {}", kind, payload_to_string(payload)),
311            attachments: Vec::new(),
312            weight: 0.6,
313            metadata: HashMap::new(),
314            timestamp,
315        }),
316        Event::SystemTrace { level, payload, .. } => Some(ContextSlice {
317            role: "system".to_string(),
318            content: format!("trace:{} {}", level, payload_to_string(payload)),
319            attachments: Vec::new(),
320            weight: 0.4,
321            metadata: HashMap::new(),
322            timestamp,
323        }),
324        Event::Artifact { reference_id, .. } => Some(ContextSlice {
325            role: "system".to_string(),
326            content: format!("artifact:{}", reference_id),
327            attachments: vec![reference_id.clone()],
328            weight: 0.5,
329            metadata: HashMap::new(),
330            timestamp,
331        }),
332    }
333}
334
335fn payload_to_string(payload: &Value) -> String {
336    if let Some(s) = payload.as_str() {
337        return s.to_string();
338    }
339    for key in ["content", "message", "text"] {
340        if let Some(s) = payload.get(key).and_then(|v| v.as_str()) {
341            return s.to_string();
342        }
343    }
344    payload.to_string()
345}
346
347#[derive(Copy, Clone)]
348enum SliceBucket {
349    Core,
350    #[allow(dead_code)]
351    Optional,
352}
353
354fn push_slice_with_budget(window: &mut ContextWindow, slice: ContextSlice, bucket: SliceBucket) {
355    let tokens = estimate_slice_tokens(&slice, &window.budget);
356    let remaining = window.budget.remaining();
357    if tokens <= remaining {
358        window.budget.used_tokens = window.budget.used_tokens.saturating_add(tokens);
359        match bucket {
360            SliceBucket::Core => window.core.push(slice),
361            SliceBucket::Optional => window.optional.push(slice),
362        }
363    } else {
364        window.deferred.push(slice);
365    }
366}
367
368fn estimate_slice_tokens(slice: &ContextSlice, budget: &TokenBudget) -> usize {
369    let attachment_chars: usize = slice.attachments.iter().map(|s| s.chars().count()).sum();
370    let total_chars = slice.content.chars().count() + attachment_chars + slice.role.chars().count();
371    std::cmp::max(
372        1,
373        (total_chars as f32 / budget.chars_per_token).ceil() as usize,
374    )
375}
376
377/// Truncate a tool output string to fit within max_tokens, preserving head + tail.
378fn truncate_tool_output(content: &str, max_tokens: usize, chars_per_token: f32) -> String {
379    let max_chars = (max_tokens as f32 * chars_per_token) as usize;
380    let total = content.chars().count();
381    if total <= max_chars {
382        return content.to_string();
383    }
384    let keep = max_chars.saturating_sub(60); // reserve room for truncation marker
385    let head_chars = keep * 2 / 5;
386    let tail_chars = keep - head_chars;
387    let head: String = content.chars().take(head_chars).collect();
388    let tail: String = content.chars().skip(total - tail_chars).collect();
389    let dropped = total - head_chars - tail_chars;
390    format!(
391        "{}\n... ({} chars truncated, {} total) ...\n{}",
392        head, dropped, total, tail
393    )
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use orchestral_core::store::{Event, InMemoryEventStore};
400    use serde_json::json;
401
402    fn make_builder() -> BasicContextBuilder {
403        let event_store = Arc::new(InMemoryEventStore::new());
404        BasicContextBuilder::new(event_store)
405    }
406
407    #[test]
408    fn test_context_budget_keeps_core_when_fit() {
409        tokio_test::block_on(async {
410            let builder = make_builder();
411            builder
412                .event_store
413                .append(Event::user_input("thread-1", "i-1", json!("hello world")))
414                .await
415                .expect("append event 1");
416            builder
417                .event_store
418                .append(Event::user_input("thread-1", "i-1", json!("second msg")))
419                .await
420                .expect("append event 2");
421
422            let mut request = ContextRequest::new("thread-1");
423            // Give enough budget to hold both (each ~5-6 tokens at 3.0 cpt)
424            request.budget = TokenBudget::new(20);
425            let window = builder.build(&request).await.expect("build context");
426            assert_eq!(window.core.len(), 2);
427            assert_eq!(window.deferred.len(), 0);
428        });
429    }
430
431    #[test]
432    fn test_context_budget_defers_core_when_overflow() {
433        tokio_test::block_on(async {
434            let builder = make_builder();
435            builder
436                .event_store
437                .append(Event::user_input("thread-2", "i-2", json!("short")))
438                .await
439                .expect("append event 1");
440            builder
441                .event_store
442                .append(Event::user_input(
443                    "thread-2",
444                    "i-2",
445                    json!("this is a much longer message that should exceed budget"),
446                ))
447                .await
448                .expect("append event 2");
449
450            let mut request = ContextRequest::new("thread-2");
451            // Budget for ~5 tokens — first event fits, second overflows
452            request.budget = TokenBudget::new(5);
453            let window = builder.build(&request).await.expect("build context");
454            assert_eq!(window.core.len(), 1);
455            assert_eq!(window.deferred.len(), 1);
456        });
457    }
458
459    #[test]
460    fn test_token_budget_estimate_tokens() {
461        let budget = TokenBudget::new(100).with_chars_per_token(3.0);
462        assert_eq!(budget.estimate_tokens("hello"), 2); // 5 chars / 3.0 = 1.67 → 2
463        assert_eq!(budget.estimate_tokens("你好世界"), 2); // 4 chars / 3.0 = 1.33 → 2
464        assert_eq!(budget.estimate_tokens(""), 1); // min 1
465    }
466
467    #[test]
468    fn test_truncate_tool_output_preserves_short() {
469        let short = "hello world";
470        assert_eq!(truncate_tool_output(short, 100, 3.0), short);
471    }
472
473    #[test]
474    fn test_truncate_tool_output_head_tail() {
475        let long: String = (0..1000).map(|i| format!("line{}\n", i)).collect();
476        let truncated = truncate_tool_output(&long, 50, 3.0); // 50*3=150 chars max
477        assert!(truncated.contains("... ("));
478        assert!(truncated.contains("chars truncated"));
479        assert!(truncated.chars().count() < long.chars().count());
480        // Head should contain "line0"
481        assert!(truncated.starts_with("line0"));
482    }
483
484    #[test]
485    fn test_compact_system_trace() {
486        let short = "trace:info ok";
487        assert_eq!(compact_system_trace(short), short);
488
489        let long = "trace:info ".to_string() + &"x".repeat(200);
490        let compacted = compact_system_trace(&long);
491        assert!(compacted.starts_with("[trace:info"));
492        assert!(compacted.contains("chars)"));
493        assert!(compacted.len() < long.len());
494    }
495
496    #[test]
497    fn test_fifo_eviction_compresses_old_system_slices() {
498        tokio_test::block_on(async {
499            let builder = make_builder();
500            // Create a conversation: user, system(large), user, system(large), user, assistant
501            let large_output = "x".repeat(500);
502            builder
503                .event_store
504                .append(Event::user_input("t", "i", json!("first question")))
505                .await
506                .unwrap();
507            builder
508                .event_store
509                .append(Event::trace("t", "info", json!(large_output)))
510                .await
511                .unwrap();
512            builder
513                .event_store
514                .append(Event::user_input("t", "i", json!("second question")))
515                .await
516                .unwrap();
517            builder
518                .event_store
519                .append(Event::trace("t", "info", json!(large_output)))
520                .await
521                .unwrap();
522            builder
523                .event_store
524                .append(Event::user_input("t", "i", json!("third question")))
525                .await
526                .unwrap();
527            builder
528                .event_store
529                .append(Event::user_input("t", "i", json!("fourth question")))
530                .await
531                .unwrap();
532
533            let mut request = ContextRequest::new("t");
534            // Budget that can't hold all content but can hold compressed version
535            request.budget = TokenBudget::new(100);
536            let window = builder.build(&request).await.expect("build context");
537
538            // Should have compressed the middle system traces
539            let system_slices: Vec<_> = window.core.iter().filter(|s| s.role == "system").collect();
540            for s in &system_slices {
541                // Compressed system slices should be short
542                assert!(
543                    s.content.chars().count() < 200,
544                    "system slice should be compacted: {}",
545                    &s.content[..80.min(s.content.len())]
546                );
547            }
548        });
549    }
550}