1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TokenBudget {
35 pub max_tokens: usize,
36 pub used_tokens: usize,
37 #[serde(default = "default_chars_per_token")]
40 pub chars_per_token: f32,
41 #[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 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ContextBuilderConfig {
140 pub history_limit: usize,
142}
143
144impl Default for ContextBuilderConfig {
145 fn default() -> Self {
146 Self { history_limit: 50 }
147 }
148}
149
150#[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#[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#[async_trait]
176pub trait ThreadSummarizer: Send + Sync {
177 async fn summarize_thread(
178 &self,
179 request: &ContextRequest,
180 ) -> Result<ThreadSummary, ContextError>;
181}
182
183#[async_trait]
185pub trait ContextBuilder: Send + Sync {
186 async fn build(&self, request: &ContextRequest) -> Result<ContextWindow, ContextError>;
187}
188
189pub 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 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 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 for slice in slices {
270 push_slice_with_budget(&mut window, slice, SliceBucket::Core);
271 }
272 }
273
274 Ok(window)
275 }
276}
277
278fn 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
377fn 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); 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 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 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); assert_eq!(budget.estimate_tokens("你好世界"), 2); assert_eq!(budget.estimate_tokens(""), 1); }
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); assert!(truncated.contains("... ("));
478 assert!(truncated.contains("chars truncated"));
479 assert!(truncated.chars().count() < long.chars().count());
480 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 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 request.budget = TokenBudget::new(100);
536 let window = builder.build(&request).await.expect("build context");
537
538 let system_slices: Vec<_> = window.core.iter().filter(|s| s.role == "system").collect();
540 for s in &system_slices {
541 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}