Skip to main content

klieo_runlog/
compaction.rs

1//! `CompactionPolicy` trait + deterministic and (optional) LLM-summarise impls.
2//!
3//! Roadmap decision-point #5: deterministic drop-by-rule is the default.
4//! LLM-summarise is opt-in behind the `compaction-llm` feature.
5
6use crate::error::RunLogError;
7use crate::types::{RunLog, Step, StepKind};
8use async_trait::async_trait;
9use std::time::Duration;
10
11/// A pluggable run-log compaction strategy.
12#[async_trait]
13pub trait CompactionPolicy: Send + Sync {
14    /// Mutate `run_log` in place to reduce its on-disk footprint.
15    async fn compact(&self, run_log: &mut RunLog) -> Result<(), RunLogError>;
16}
17
18/// Deterministic drop-by-rule compaction. Default policy.
19///
20/// - Keeps the most recent `max_steps` verbatim.
21/// - Drops any step whose `kind` matches `drop_kinds` first.
22/// - When still over `max_steps`, the older overflow steps are replaced by a
23///   single summary step (kind = `ToolCall`, name = `"compacted"`,
24///   input = `{ "dropped_count": N }`).
25/// - Re-indexes `Step::idx` after compaction.
26pub struct DeterministicCompaction {
27    /// Maximum number of `Step`s kept verbatim. Older overflow becomes a single summary step.
28    pub max_steps: u32,
29    /// Step kinds dropped unconditionally before the `max_steps` truncation runs.
30    pub drop_kinds: Vec<StepKind>,
31}
32
33impl Default for DeterministicCompaction {
34    fn default() -> Self {
35        Self {
36            max_steps: 100,
37            drop_kinds: vec![],
38        }
39    }
40}
41
42#[async_trait]
43impl CompactionPolicy for DeterministicCompaction {
44    async fn compact(&self, run_log: &mut RunLog) -> Result<(), RunLogError> {
45        // 1. Drop kinds unconditionally.
46        if !self.drop_kinds.is_empty() {
47            run_log.steps.retain(|s| !self.drop_kinds.contains(&s.kind));
48        }
49
50        // 2. Truncate older overflow into a summary step.
51        let max = self.max_steps as usize;
52        if run_log.steps.len() > max {
53            let total = run_log.steps.len();
54            let dropped = total - max;
55            let kept_tail: Vec<Step> = run_log.steps.split_off(dropped);
56            // run_log.steps now holds the older `dropped` steps; replace with summary.
57            run_log.steps = vec![Step {
58                idx: 0,
59                kind: StepKind::ToolCall,
60                name: Some("compacted".into()),
61                prompt_tokens: None,
62                completion_tokens: None,
63                cost_usd: None,
64                input: serde_json::json!({ "dropped_count": dropped }),
65                output: serde_json::Value::Null,
66                error: None,
67                latency: Duration::ZERO,
68                span_id: None,
69            }];
70            run_log.steps.extend(kept_tail);
71        }
72
73        // 3. Re-index.
74        for (i, s) in run_log.steps.iter_mut().enumerate() {
75            s.idx = i as u32;
76        }
77        Ok(())
78    }
79}
80
81#[cfg(feature = "compaction-llm")]
82mod llm {
83    use super::{CompactionPolicy, RunLogError, Step, StepKind};
84    use crate::types::RunLog;
85    use async_trait::async_trait;
86    use klieo_core::llm::{ChatRequest, LlmClient, Message, Role};
87    use std::sync::Arc;
88    use std::time::Duration;
89
90    /// LLM-summarise compaction. Available behind the `compaction-llm` feature.
91    ///
92    /// When `run_log.steps.len() > max_steps`, the older overflow is summarised
93    /// by calling `llm.complete(...)` with a single `User` message that asks for
94    /// a one-sentence summary. The summary replaces the older steps as a single
95    /// `ToolCall` step (name `"compacted"`, output = the summary text).
96    pub struct LlmCompaction {
97        /// LLM client used to produce the summary string.
98        pub llm: Arc<dyn LlmClient>,
99        /// Maximum number of `Step`s kept verbatim.
100        pub max_steps: u32,
101    }
102
103    #[async_trait]
104    impl CompactionPolicy for LlmCompaction {
105        async fn compact(&self, run_log: &mut RunLog) -> Result<(), RunLogError> {
106            let max = self.max_steps as usize;
107            if run_log.steps.len() <= max {
108                return Ok(());
109            }
110            let total = run_log.steps.len();
111            let dropped = total - max;
112            // Build a compact prompt of older steps.
113            let older_json = serde_json::to_string(&run_log.steps[..dropped])
114                .map_err(|e| RunLogError::Compaction(e.to_string()))?;
115            let prompt = format!(
116                "Summarise the following {dropped} agent steps in one sentence:\n{older_json}"
117            );
118            // klieo-core's LlmClient::complete takes a ChatRequest and returns
119            // a ChatResponse — wrap the string prompt as a single User message
120            // and pull `message.content` out of the response.
121            let req = ChatRequest::new(vec![Message {
122                role: Role::User,
123                content: prompt,
124                tool_calls: vec![],
125                tool_call_id: None,
126            }]);
127            let resp = self
128                .llm
129                .complete(req)
130                .await
131                .map_err(|e| RunLogError::Compaction(e.to_string()))?;
132            let summary = resp.message.content;
133            let kept_tail: Vec<Step> = run_log.steps.split_off(dropped);
134            run_log.steps = vec![Step {
135                idx: 0,
136                kind: StepKind::ToolCall,
137                name: Some("compacted".into()),
138                prompt_tokens: None,
139                completion_tokens: None,
140                cost_usd: None,
141                input: serde_json::json!({ "dropped_count": dropped }),
142                output: serde_json::Value::String(summary),
143                error: None,
144                latency: Duration::ZERO,
145                span_id: None,
146            }];
147            run_log.steps.extend(kept_tail);
148            for (i, s) in run_log.steps.iter_mut().enumerate() {
149                s.idx = i as u32;
150            }
151            Ok(())
152        }
153    }
154}
155
156#[cfg(feature = "compaction-llm")]
157pub use llm::LlmCompaction;