Skip to main content

enact_context/
condenser.rs

1//! Result Condenser
2//!
3//! Condenses child execution traces to 1-2k token summaries.
4//! Used to compress child callable results back into parent context.
5//!
6//! @see packages/enact-schemas/src/context.schemas.ts
7
8use 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
16/// Global sequence counter for segments
17static CONDENSE_SEQUENCE: AtomicU64 = AtomicU64::new(3000);
18
19fn next_sequence() -> u64 {
20    CONDENSE_SEQUENCE.fetch_add(1, Ordering::SeqCst)
21}
22
23/// Condensation configuration
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct CondenserConfig {
27    /// Target token count for condensed result
28    pub target_tokens: usize,
29
30    /// Maximum token count (hard limit)
31    pub max_tokens: usize,
32
33    /// Include step summaries
34    pub include_steps: bool,
35
36    /// Maximum steps to summarize
37    pub max_steps: usize,
38
39    /// Include tool call summaries
40    pub include_tools: bool,
41
42    /// Include error summaries
43    pub include_errors: bool,
44
45    /// Include timing information
46    pub include_timing: bool,
47
48    /// Preserve key decisions
49    pub preserve_decisions: bool,
50
51    /// Maximum decision count
52    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    /// Minimal config for brief summaries
73    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    /// Detailed config for comprehensive summaries
88    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/// Summary of a step for condensation
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StepSummary {
106    /// Step ID
107    pub step_id: StepId,
108
109    /// Step type/name
110    pub step_type: String,
111
112    /// Brief description of what happened
113    pub summary: String,
114
115    /// Whether step succeeded
116    pub success: bool,
117
118    /// Duration in milliseconds
119    pub duration_ms: Option<u64>,
120
121    /// Key output (truncated if needed)
122    pub key_output: Option<String>,
123}
124
125/// Summary of a decision made during execution
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DecisionSummary {
128    /// What decision was made
129    pub decision: String,
130
131    /// Rationale for the decision
132    pub rationale: String,
133
134    /// Confidence level
135    pub confidence: f64,
136
137    /// Step where decision was made
138    pub step_id: StepId,
139}
140
141/// Input for condensation - represents a child execution trace
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ExecutionTrace {
144    /// Execution ID
145    pub execution_id: ExecutionId,
146
147    /// Parent execution ID (if any)
148    pub parent_execution_id: Option<ExecutionId>,
149
150    /// Parent step that spawned this execution
151    pub parent_step_id: Option<StepId>,
152
153    /// Execution start time
154    pub started_at: DateTime<Utc>,
155
156    /// Execution end time
157    pub ended_at: Option<DateTime<Utc>>,
158
159    /// Final status
160    pub status: ExecutionStatus,
161
162    /// Step summaries
163    pub steps: Vec<StepSummary>,
164
165    /// Key decisions made
166    pub decisions: Vec<DecisionSummary>,
167
168    /// Final output/result
169    pub final_output: Option<String>,
170
171    /// Error message if failed
172    pub error: Option<String>,
173
174    /// Metadata
175    pub metadata: HashMap<String, String>,
176}
177
178/// Execution status for trace
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ExecutionStatus {
182    /// Completed successfully
183    Completed,
184    /// Failed with error
185    Failed,
186    /// Cancelled by user/system
187    Cancelled,
188    /// Timed out
189    TimedOut,
190    /// Still running
191    Running,
192}
193
194impl ExecutionTrace {
195    /// Create a new trace
196    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    /// Create with parent context
213    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    /// Mark as completed
234    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    /// Mark as failed
242    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    /// Add a step summary
250    pub fn add_step(mut self, step: StepSummary) -> Self {
251        self.steps.push(step);
252        self
253    }
254
255    /// Add a decision
256    pub fn add_decision(mut self, decision: DecisionSummary) -> Self {
257        self.decisions.push(decision);
258        self
259    }
260
261    /// Get duration in milliseconds
262    pub fn duration_ms(&self) -> Option<i64> {
263        self.ended_at
264            .map(|end| (end - self.started_at).num_milliseconds())
265    }
266}
267
268/// Result of condensation
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct CondensedResult {
272    /// Source execution ID
273    pub execution_id: ExecutionId,
274
275    /// Condensed summary text
276    pub summary: String,
277
278    /// Key outcomes
279    pub outcomes: Vec<String>,
280
281    /// Important learnings
282    pub learnings: Vec<String>,
283
284    /// Context segment for parent
285    pub context_segment: ContextSegment,
286
287    /// Token count of condensed result
288    pub token_count: usize,
289
290    /// Original trace token estimate
291    pub original_tokens: usize,
292
293    /// Compression ratio
294    pub compression_ratio: f64,
295
296    /// Condensation timestamp
297    pub condensed_at: DateTime<Utc>,
298}
299
300/// Result Condenser - compresses execution traces for parent context
301pub struct ResultCondenser {
302    token_counter: TokenCounter,
303    config: CondenserConfig,
304}
305
306impl ResultCondenser {
307    /// Create with default config
308    pub fn new() -> Self {
309        Self {
310            token_counter: TokenCounter::default(),
311            config: CondenserConfig::default(),
312        }
313    }
314
315    /// Create with custom config
316    pub fn with_config(config: CondenserConfig) -> Self {
317        Self {
318            token_counter: TokenCounter::default(),
319            config,
320        }
321    }
322
323    /// Condense an execution trace
324    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        // Header with execution info
330        let header = self.build_header(trace);
331        parts.push(header);
332
333        // Status and result
334        let status_section = self.build_status_section(trace, &mut outcomes);
335        parts.push(status_section);
336
337        // Steps summary (if enabled)
338        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        // Decisions (if enabled)
344        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        // Error details (if any)
350        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        // Combine and check token count
358        let mut summary = parts.join("\n\n");
359        let mut token_count = self.token_counter.count(&summary);
360
361        // Truncate if over limit
362        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        // Estimate original token count
371        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        // Create context segment for parent
379        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    /// Build header section
411    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    /// Build status section
428    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    /// Build steps section
449    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    /// Build decisions section
483    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    /// Estimate original token count from trace
510    fn estimate_original_tokens(&self, trace: &ExecutionTrace) -> usize {
511        let mut estimate = 0;
512
513        // Estimate from steps
514        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        // Estimate from decisions
522        for decision in &trace.decisions {
523            estimate += self.token_counter.count(&decision.decision);
524            estimate += self.token_counter.count(&decision.rationale);
525        }
526
527        // Final output
528        if let Some(output) = &trace.final_output {
529            estimate += self.token_counter.count(output);
530        }
531
532        // Error
533        if let Some(error) = &trace.error {
534            estimate += self.token_counter.count(error);
535        }
536
537        estimate
538    }
539
540    /// Truncate text to max length
541    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    /// Condense multiple traces (e.g., parallel child executions)
550    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        // Multi-trace condensation
578        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        // Summarize each trace briefly
586        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        // Create trace with lots of content
749        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); // Some tolerance
768    }
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        // Add substantial content with long outputs
792        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        // Verify condensation happened (original should be larger due to key_output not being included)
807        assert!(result.original_tokens > 0);
808        assert!(result.token_count > 0);
809        // The condenser limits steps and truncates content, so we should see some compression
810        // Note: compression_ratio = token_count / original_tokens
811        // Due to formatting overhead, ratio might be close to 1.0 but original should still be larger
812        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        // Successful execution should have medium priority
825        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        // Failed execution should have high priority
833        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}