1use crate::segment::{ContextPriority, ContextSegment};
9use crate::token_counter::TokenCounter;
10use chrono::{DateTime, Utc};
11use enact_core::kernel::{ExecutionId, StepId};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16static CONDENSE_SEQUENCE: AtomicU64 = AtomicU64::new(3000);
18
19fn next_sequence() -> u64 {
20 CONDENSE_SEQUENCE.fetch_add(1, Ordering::SeqCst)
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct CondenserConfig {
27 pub target_tokens: usize,
29
30 pub max_tokens: usize,
32
33 pub include_steps: bool,
35
36 pub max_steps: usize,
38
39 pub include_tools: bool,
41
42 pub include_errors: bool,
44
45 pub include_timing: bool,
47
48 pub preserve_decisions: bool,
50
51 pub max_decisions: usize,
53}
54
55impl Default for CondenserConfig {
56 fn default() -> Self {
57 Self {
58 target_tokens: 1500,
59 max_tokens: 2000,
60 include_steps: true,
61 max_steps: 10,
62 include_tools: true,
63 include_errors: true,
64 include_timing: true,
65 preserve_decisions: true,
66 max_decisions: 5,
67 }
68 }
69}
70
71impl CondenserConfig {
72 pub fn minimal() -> Self {
74 Self {
75 target_tokens: 500,
76 max_tokens: 750,
77 include_steps: false,
78 max_steps: 3,
79 include_tools: false,
80 include_errors: true,
81 include_timing: false,
82 preserve_decisions: true,
83 max_decisions: 2,
84 }
85 }
86
87 pub fn detailed() -> Self {
89 Self {
90 target_tokens: 3000,
91 max_tokens: 4000,
92 include_steps: true,
93 max_steps: 20,
94 include_tools: true,
95 include_errors: true,
96 include_timing: true,
97 preserve_decisions: true,
98 max_decisions: 10,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StepSummary {
106 pub step_id: StepId,
108
109 pub step_type: String,
111
112 pub summary: String,
114
115 pub success: bool,
117
118 pub duration_ms: Option<u64>,
120
121 pub key_output: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DecisionSummary {
128 pub decision: String,
130
131 pub rationale: String,
133
134 pub confidence: f64,
136
137 pub step_id: StepId,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ExecutionTrace {
144 pub execution_id: ExecutionId,
146
147 pub parent_execution_id: Option<ExecutionId>,
149
150 pub parent_step_id: Option<StepId>,
152
153 pub started_at: DateTime<Utc>,
155
156 pub ended_at: Option<DateTime<Utc>>,
158
159 pub status: ExecutionStatus,
161
162 pub steps: Vec<StepSummary>,
164
165 pub decisions: Vec<DecisionSummary>,
167
168 pub final_output: Option<String>,
170
171 pub error: Option<String>,
173
174 pub metadata: HashMap<String, String>,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ExecutionStatus {
182 Completed,
184 Failed,
186 Cancelled,
188 TimedOut,
190 Running,
192}
193
194impl ExecutionTrace {
195 pub fn new(execution_id: ExecutionId) -> Self {
197 Self {
198 execution_id,
199 parent_execution_id: None,
200 parent_step_id: None,
201 started_at: Utc::now(),
202 ended_at: None,
203 status: ExecutionStatus::Running,
204 steps: Vec::new(),
205 decisions: Vec::new(),
206 final_output: None,
207 error: None,
208 metadata: HashMap::new(),
209 }
210 }
211
212 pub fn with_parent(
214 execution_id: ExecutionId,
215 parent_execution_id: ExecutionId,
216 parent_step_id: StepId,
217 ) -> Self {
218 Self {
219 execution_id,
220 parent_execution_id: Some(parent_execution_id),
221 parent_step_id: Some(parent_step_id),
222 started_at: Utc::now(),
223 ended_at: None,
224 status: ExecutionStatus::Running,
225 steps: Vec::new(),
226 decisions: Vec::new(),
227 final_output: None,
228 error: None,
229 metadata: HashMap::new(),
230 }
231 }
232
233 pub fn complete(mut self, output: impl Into<String>) -> Self {
235 self.ended_at = Some(Utc::now());
236 self.status = ExecutionStatus::Completed;
237 self.final_output = Some(output.into());
238 self
239 }
240
241 pub fn fail(mut self, error: impl Into<String>) -> Self {
243 self.ended_at = Some(Utc::now());
244 self.status = ExecutionStatus::Failed;
245 self.error = Some(error.into());
246 self
247 }
248
249 pub fn add_step(mut self, step: StepSummary) -> Self {
251 self.steps.push(step);
252 self
253 }
254
255 pub fn add_decision(mut self, decision: DecisionSummary) -> Self {
257 self.decisions.push(decision);
258 self
259 }
260
261 pub fn duration_ms(&self) -> Option<i64> {
263 self.ended_at
264 .map(|end| (end - self.started_at).num_milliseconds())
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct CondensedResult {
272 pub execution_id: ExecutionId,
274
275 pub summary: String,
277
278 pub outcomes: Vec<String>,
280
281 pub learnings: Vec<String>,
283
284 pub context_segment: ContextSegment,
286
287 pub token_count: usize,
289
290 pub original_tokens: usize,
292
293 pub compression_ratio: f64,
295
296 pub condensed_at: DateTime<Utc>,
298}
299
300pub struct ResultCondenser {
302 token_counter: TokenCounter,
303 config: CondenserConfig,
304}
305
306impl ResultCondenser {
307 pub fn new() -> Self {
309 Self {
310 token_counter: TokenCounter::default(),
311 config: CondenserConfig::default(),
312 }
313 }
314
315 pub fn with_config(config: CondenserConfig) -> Self {
317 Self {
318 token_counter: TokenCounter::default(),
319 config,
320 }
321 }
322
323 pub fn condense(&self, trace: &ExecutionTrace) -> CondensedResult {
325 let mut parts: Vec<String> = Vec::new();
326 let mut outcomes: Vec<String> = Vec::new();
327 let mut learnings: Vec<String> = Vec::new();
328
329 let header = self.build_header(trace);
331 parts.push(header);
332
333 let status_section = self.build_status_section(trace, &mut outcomes);
335 parts.push(status_section);
336
337 if self.config.include_steps && !trace.steps.is_empty() {
339 let steps_section = self.build_steps_section(&trace.steps);
340 parts.push(steps_section);
341 }
342
343 if self.config.preserve_decisions && !trace.decisions.is_empty() {
345 let decisions_section = self.build_decisions_section(&trace.decisions, &mut learnings);
346 parts.push(decisions_section);
347 }
348
349 if self.config.include_errors {
351 if let Some(error) = &trace.error {
352 parts.push(format!("Error: {}", self.truncate(error, 200)));
353 learnings.push(format!("Failure mode: {}", self.truncate(error, 100)));
354 }
355 }
356
357 let mut summary = parts.join("\n\n");
359 let mut token_count = self.token_counter.count(&summary);
360
361 if token_count > self.config.max_tokens {
363 let (truncated, new_count) = self
364 .token_counter
365 .truncate(&summary, self.config.target_tokens);
366 summary = truncated;
367 token_count = new_count;
368 }
369
370 let original_tokens = self.estimate_original_tokens(trace);
372 let compression_ratio = if original_tokens > 0 {
373 token_count as f64 / original_tokens as f64
374 } else {
375 1.0
376 };
377
378 let segment_content = format!(
380 "[Child Execution: {}]\n{}",
381 trace.execution_id.as_str(),
382 summary
383 );
384 let segment_tokens = self.token_counter.count(&segment_content);
385 let context_segment = ContextSegment::child_summary(
386 segment_content,
387 segment_tokens,
388 next_sequence(),
389 trace.parent_step_id.clone().unwrap_or_default(),
390 )
391 .with_priority(if trace.status == ExecutionStatus::Completed {
392 ContextPriority::Medium
393 } else {
394 ContextPriority::High
395 });
396
397 CondensedResult {
398 execution_id: trace.execution_id.clone(),
399 summary,
400 outcomes,
401 learnings,
402 context_segment,
403 token_count,
404 original_tokens,
405 compression_ratio,
406 condensed_at: Utc::now(),
407 }
408 }
409
410 fn build_header(&self, trace: &ExecutionTrace) -> String {
412 let mut header = format!("Execution: {}", trace.execution_id.as_str());
413
414 if self.config.include_timing {
415 if let Some(duration) = trace.duration_ms() {
416 header.push_str(&format!(" ({}ms)", duration));
417 }
418 }
419
420 if let Some(parent) = &trace.parent_step_id {
421 header.push_str(&format!("\nSpawned from: {}", parent.as_str()));
422 }
423
424 header
425 }
426
427 fn build_status_section(&self, trace: &ExecutionTrace, outcomes: &mut Vec<String>) -> String {
429 let status_str = match trace.status {
430 ExecutionStatus::Completed => "COMPLETED",
431 ExecutionStatus::Failed => "FAILED",
432 ExecutionStatus::Cancelled => "CANCELLED",
433 ExecutionStatus::TimedOut => "TIMED_OUT",
434 ExecutionStatus::Running => "RUNNING",
435 };
436
437 let mut section = format!("Status: {}", status_str);
438
439 if let Some(output) = &trace.final_output {
440 let truncated = self.truncate(output, 300);
441 section.push_str(&format!("\nResult: {}", truncated));
442 outcomes.push(format!("Output: {}", self.truncate(output, 100)));
443 }
444
445 section
446 }
447
448 fn build_steps_section(&self, steps: &[StepSummary]) -> String {
450 let steps_to_show: Vec<_> = steps.iter().take(self.config.max_steps).collect();
451 let total = steps.len();
452 let shown = steps_to_show.len();
453
454 let mut lines: Vec<String> = vec![format!("Steps ({}/{}):", shown, total)];
455
456 for (i, step) in steps_to_show.iter().enumerate() {
457 let status = if step.success { "✓" } else { "✗" };
458 let mut line = format!(
459 " {}. {} {} - {}",
460 i + 1,
461 status,
462 step.step_type,
463 self.truncate(&step.summary, 50)
464 );
465
466 if self.config.include_timing {
467 if let Some(ms) = step.duration_ms {
468 line.push_str(&format!(" ({}ms)", ms));
469 }
470 }
471
472 lines.push(line);
473 }
474
475 if total > shown {
476 lines.push(format!(" ... and {} more steps", total - shown));
477 }
478
479 lines.join("\n")
480 }
481
482 fn build_decisions_section(
484 &self,
485 decisions: &[DecisionSummary],
486 learnings: &mut Vec<String>,
487 ) -> String {
488 let decisions_to_show: Vec<_> = decisions.iter().take(self.config.max_decisions).collect();
489
490 let mut lines: Vec<String> = vec!["Key Decisions:".to_string()];
491
492 for decision in decisions_to_show {
493 lines.push(format!(
494 " • {} (confidence: {:.0}%)",
495 self.truncate(&decision.decision, 80),
496 decision.confidence * 100.0
497 ));
498
499 learnings.push(format!(
500 "Decision: {} - Rationale: {}",
501 self.truncate(&decision.decision, 50),
502 self.truncate(&decision.rationale, 50)
503 ));
504 }
505
506 lines.join("\n")
507 }
508
509 fn estimate_original_tokens(&self, trace: &ExecutionTrace) -> usize {
511 let mut estimate = 0;
512
513 for step in &trace.steps {
515 estimate += self.token_counter.count(&step.summary);
516 if let Some(output) = &step.key_output {
517 estimate += self.token_counter.count(output);
518 }
519 }
520
521 for decision in &trace.decisions {
523 estimate += self.token_counter.count(&decision.decision);
524 estimate += self.token_counter.count(&decision.rationale);
525 }
526
527 if let Some(output) = &trace.final_output {
529 estimate += self.token_counter.count(output);
530 }
531
532 if let Some(error) = &trace.error {
534 estimate += self.token_counter.count(error);
535 }
536
537 estimate
538 }
539
540 fn truncate(&self, text: &str, max_len: usize) -> String {
542 if text.len() <= max_len {
543 text.to_string()
544 } else {
545 format!("{}...", &text[..max_len.saturating_sub(3)])
546 }
547 }
548
549 pub fn condense_multiple(&self, traces: &[ExecutionTrace]) -> CondensedResult {
551 if traces.is_empty() {
552 let empty_segment = ContextSegment::child_summary(
553 "No child executions".to_string(),
554 3,
555 next_sequence(),
556 StepId::new(),
557 )
558 .with_priority(ContextPriority::Low);
559
560 return CondensedResult {
561 execution_id: ExecutionId::new(),
562 summary: "No executions to condense".to_string(),
563 outcomes: Vec::new(),
564 learnings: Vec::new(),
565 context_segment: empty_segment,
566 token_count: 0,
567 original_tokens: 0,
568 compression_ratio: 1.0,
569 condensed_at: Utc::now(),
570 };
571 }
572
573 if traces.len() == 1 {
574 return self.condense(&traces[0]);
575 }
576
577 let mut parts: Vec<String> = Vec::new();
579 let mut all_outcomes: Vec<String> = Vec::new();
580 let mut all_learnings: Vec<String> = Vec::new();
581 let mut total_original = 0;
582
583 parts.push(format!("Parallel Executions: {} total", traces.len()));
584
585 let tokens_per_trace = self.config.target_tokens / traces.len();
587 for (i, trace) in traces.iter().enumerate() {
588 let brief_config = CondenserConfig {
589 target_tokens: tokens_per_trace,
590 max_tokens: tokens_per_trace + 100,
591 include_steps: false,
592 max_steps: 3,
593 ..self.config.clone()
594 };
595
596 let condenser = ResultCondenser::with_config(brief_config);
597 let condensed = condenser.condense(trace);
598
599 parts.push(format!(
600 "\n[{}/{}] {}",
601 i + 1,
602 traces.len(),
603 condensed.summary
604 ));
605 all_outcomes.extend(condensed.outcomes);
606 all_learnings.extend(condensed.learnings);
607 total_original += condensed.original_tokens;
608 }
609
610 let summary = parts.join("\n");
611 let token_count = self.token_counter.count(&summary);
612
613 let segment_content = format!("[Parallel Executions]\n{}", summary);
614 let segment_tokens = self.token_counter.count(&segment_content);
615 let context_segment = ContextSegment::child_summary(
616 segment_content,
617 segment_tokens,
618 next_sequence(),
619 traces[0].parent_step_id.clone().unwrap_or_default(),
620 )
621 .with_priority(ContextPriority::Medium);
622
623 CondensedResult {
624 execution_id: traces[0].execution_id.clone(),
625 summary,
626 outcomes: all_outcomes,
627 learnings: all_learnings,
628 context_segment,
629 token_count,
630 original_tokens: total_original,
631 compression_ratio: if total_original > 0 {
632 token_count as f64 / total_original as f64
633 } else {
634 1.0
635 },
636 condensed_at: Utc::now(),
637 }
638 }
639}
640
641impl Default for ResultCondenser {
642 fn default() -> Self {
643 Self::new()
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 fn test_execution_id() -> ExecutionId {
652 ExecutionId::new()
653 }
654
655 fn test_step_id() -> StepId {
656 StepId::new()
657 }
658
659 #[test]
660 fn test_condenser_config_defaults() {
661 let config = CondenserConfig::default();
662 assert_eq!(config.target_tokens, 1500);
663 assert!(config.include_steps);
664 }
665
666 #[test]
667 fn test_condense_simple_trace() {
668 let condenser = ResultCondenser::new();
669 let trace =
670 ExecutionTrace::new(test_execution_id()).complete("Task completed successfully");
671
672 let result = condenser.condense(&trace);
673
674 assert!(result.summary.contains("COMPLETED"));
675 assert!(result.token_count > 0);
676 }
677
678 #[test]
679 fn test_condense_with_steps() {
680 let condenser = ResultCondenser::new();
681 let trace = ExecutionTrace::new(test_execution_id())
682 .add_step(StepSummary {
683 step_id: test_step_id(),
684 step_type: "llm_call".to_string(),
685 summary: "Generated response".to_string(),
686 success: true,
687 duration_ms: Some(500),
688 key_output: Some("Response text".to_string()),
689 })
690 .add_step(StepSummary {
691 step_id: test_step_id(),
692 step_type: "tool_call".to_string(),
693 summary: "Called search API".to_string(),
694 success: true,
695 duration_ms: Some(200),
696 key_output: None,
697 })
698 .complete("Done");
699
700 let result = condenser.condense(&trace);
701
702 assert!(result.summary.contains("Steps"));
703 assert!(result.summary.contains("llm_call"));
704 assert!(result.summary.contains("tool_call"));
705 }
706
707 #[test]
708 fn test_condense_failed_trace() {
709 let condenser = ResultCondenser::new();
710 let trace =
711 ExecutionTrace::new(test_execution_id()).fail("Connection timeout after 30 seconds");
712
713 let result = condenser.condense(&trace);
714
715 assert!(result.summary.contains("FAILED"));
716 assert!(result.summary.contains("timeout"));
717 assert!(!result.learnings.is_empty());
718 }
719
720 #[test]
721 fn test_condense_with_decisions() {
722 let condenser = ResultCondenser::new();
723 let trace = ExecutionTrace::new(test_execution_id())
724 .add_decision(DecisionSummary {
725 decision: "Use caching strategy".to_string(),
726 rationale: "Reduce API calls".to_string(),
727 confidence: 0.85,
728 step_id: test_step_id(),
729 })
730 .complete("Done");
731
732 let result = condenser.condense(&trace);
733
734 assert!(result.summary.contains("Key Decisions"));
735 assert!(result.summary.contains("caching"));
736 assert!(!result.learnings.is_empty());
737 }
738
739 #[test]
740 fn test_condense_respects_token_limit() {
741 let config = CondenserConfig {
742 max_tokens: 100,
743 target_tokens: 50,
744 ..Default::default()
745 };
746 let condenser = ResultCondenser::with_config(config);
747
748 let mut trace = ExecutionTrace::new(test_execution_id());
750 for i in 0..20 {
751 trace = trace.add_step(StepSummary {
752 step_id: test_step_id(),
753 step_type: format!("step_{}", i),
754 summary: format!(
755 "This is a detailed summary of step {} with lots of information",
756 i
757 ),
758 success: true,
759 duration_ms: Some(100),
760 key_output: Some(format!("Output from step {}", i)),
761 });
762 }
763 trace = trace.complete("Final result with lots of detail");
764
765 let result = condenser.condense(&trace);
766
767 assert!(result.token_count <= 150); }
769
770 #[test]
771 fn test_condense_multiple_traces() {
772 let condenser = ResultCondenser::new();
773 let traces = vec![
774 ExecutionTrace::new(test_execution_id()).complete("Result 1"),
775 ExecutionTrace::new(test_execution_id()).complete("Result 2"),
776 ExecutionTrace::new(test_execution_id()).fail("Error in trace 3"),
777 ];
778
779 let result = condenser.condense_multiple(&traces);
780
781 assert!(result.summary.contains("Parallel Executions: 3"));
782 assert!(result.summary.contains("COMPLETED"));
783 assert!(result.summary.contains("FAILED"));
784 }
785
786 #[test]
787 fn test_compression_ratio() {
788 let condenser = ResultCondenser::new();
789 let mut trace = ExecutionTrace::new(test_execution_id());
790
791 for i in 0..10 {
793 trace = trace.add_step(StepSummary {
794 step_id: test_step_id(),
795 step_type: "step".to_string(),
796 summary: format!("Detailed summary for step {} with additional context and more information to ensure we have enough content", i),
797 success: true,
798 duration_ms: Some(100),
799 key_output: Some(format!("Long output content for step {} that adds more tokens and even more details to increase the token count significantly beyond what will be included in the final summary. This should definitely be truncated.", i)),
800 });
801 }
802 trace = trace.complete("Comprehensive final output with all the details and extra information that extends the content significantly.");
803
804 let result = condenser.condense(&trace);
805
806 assert!(result.original_tokens > 0);
808 assert!(result.token_count > 0);
809 assert!(
813 result.original_tokens >= result.token_count / 2,
814 "Original tokens ({}) should be at least half of final tokens ({})",
815 result.original_tokens,
816 result.token_count
817 );
818 }
819
820 #[test]
821 fn test_context_segment_priority() {
822 let condenser = ResultCondenser::new();
823
824 let success_trace = ExecutionTrace::new(test_execution_id()).complete("Done");
826 let success_result = condenser.condense(&success_trace);
827 assert_eq!(
828 success_result.context_segment.priority,
829 ContextPriority::Medium
830 );
831
832 let fail_trace = ExecutionTrace::new(test_execution_id()).fail("Error");
834 let fail_result = condenser.condense(&fail_trace);
835 assert_eq!(fail_result.context_segment.priority, ContextPriority::High);
836 }
837}