Skip to main content

zeph_session/
llm_condenser.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`LlmCondenser`]: the default [`Condenser`] implementation, reusing
5//! `zeph_context::summarization::summarize_structured` for durable, replayable condensation
6//! (spec §8).
7
8use zeph_context::summarization::{SummarizationDeps, summarize_structured};
9use zeph_llm::provider::{Message, MessagePart, Role};
10
11use crate::condenser::{CondensationResult, Condenser, validate_non_overlap};
12use crate::error::SessionError;
13use crate::event::{SessionEvent, SessionEventEnvelope};
14use crate::replay::{ReconstructedState, ReplayEngine};
15
16const CONDENSATION_GUIDELINES: &str = "Summarize the conversation so far, preserving the \
17    session's intent, files modified, decisions made, open questions, and next steps. This \
18    summary durably replaces the condensed portion of the event log — be precise, do not invent \
19    details.";
20
21/// Default [`Condenser`] implementation: summarizes a session's tail via an LLM, reusing
22/// `zeph_context::summarization::summarize_structured`. Distinct from live in-memory compaction
23/// (owned by `zeph-context`) — this operates on the durable event log and is recorded as a
24/// [`crate::event::SessionEvent::Condensation`] event so replay can fold the same summary
25/// deterministically (spec §8, AC-6).
26pub struct LlmCondenser {
27    deps: SummarizationDeps,
28    /// Trigger threshold: [`Condenser::should_condense`] returns `true` once
29    /// `budget_used_fraction` reaches this value (`0.0..=1.0`).
30    threshold: f64,
31    /// Number of trailing messages (`UserMessage`/`AssistantMessage`-initiated) to always keep
32    /// un-condensed.
33    keep_recent: usize,
34}
35
36impl LlmCondenser {
37    /// Construct a new condenser with the given LLM dependencies, trigger threshold (fraction of
38    /// context budget used, `0.0..=1.0`), and number of trailing messages to always keep.
39    #[must_use]
40    pub fn new(deps: SummarizationDeps, threshold: f64, keep_recent: usize) -> Self {
41        Self {
42            deps,
43            threshold,
44            keep_recent,
45        }
46    }
47}
48
49impl Condenser for LlmCondenser {
50    async fn should_condense(&self, state: &ReconstructedState, budget_used_fraction: f64) -> bool {
51        budget_used_fraction >= self.threshold && state.messages.len() > self.keep_recent
52    }
53
54    #[tracing::instrument(
55        name = "session.condenser.condense",
56        skip_all,
57        level = "info",
58        fields(event_count = events.len(), last_condensed_seq)
59    )]
60    async fn condense(
61        &self,
62        events: &[SessionEventEnvelope],
63        last_condensed_seq: u64,
64    ) -> Result<CondensationResult, SessionError> {
65        // N2 (impl-critic re-verify finding): restrict to events strictly after the INV-SP-4
66        // watermark before computing boundaries. Without this, a *second* condensation call
67        // re-includes the first's already-condensed range (callers pass the full log, not a
68        // pre-sliced tail), `to_condense` starts back near seq 0, and `validate_non_overlap`
69        // rejects every condensation after the first — durable condensation degrades to
70        // single-shot per session even though the caller keeps invoking it on every resume.
71        let uncondensed: Vec<SessionEventEnvelope> = events
72            .iter()
73            .filter(|e| e.seq > last_condensed_seq)
74            .cloned()
75            .collect();
76
77        // Indices of events that start a new agent-ready message; `ToolCall` attaches to the
78        // preceding `AssistantMessage` boundary and `ToolResult` to its own open tool-result
79        // batch (see `ReplayEngine::fold`) rather than starting a counted boundary of its own.
80        let boundaries: Vec<usize> = uncondensed
81            .iter()
82            .enumerate()
83            .filter(|(_, e)| {
84                matches!(
85                    e.kind,
86                    SessionEvent::UserMessage { .. } | SessionEvent::AssistantMessage { .. }
87                )
88            })
89            .map(|(i, _)| i)
90            .collect();
91
92        if boundaries.len() <= self.keep_recent {
93            return Err(SessionError::CondensationOverlap(format!(
94                "not enough events to condense: {} message(s) available, keep_recent={}",
95                boundaries.len(),
96                self.keep_recent
97            )));
98        }
99
100        let cutoff = boundaries[boundaries.len() - self.keep_recent];
101        let to_condense = &uncondensed[..cutoff];
102        let lo = to_condense
103            .first()
104            .map_or(last_condensed_seq + 1, |e| e.seq);
105        let hi = to_condense.last().map_or(last_condensed_seq, |e| e.seq);
106        validate_non_overlap(last_condensed_seq, (lo, hi))?;
107
108        let folded = ReplayEngine::fold(to_condense.to_vec(), None);
109        let tokens_before: usize = folded
110            .messages
111            .iter()
112            .map(|m| self.deps.token_counter.count_message_tokens(m))
113            .sum();
114
115        let summary = summarize_structured(&self.deps, &folded.messages, CONDENSATION_GUIDELINES)
116            .await
117            .map_err(SessionError::Llm)?;
118
119        let summary_message = Message::from_parts(
120            Role::System,
121            vec![MessagePart::Summary {
122                text: summary.to_markdown(),
123            }],
124        );
125        let tokens_after = self
126            .deps
127            .token_counter
128            .count_message_tokens(&summary_message);
129
130        Ok(CondensationResult {
131            replaced_range: (lo, hi),
132            summary,
133            tokens_before: u32::try_from(tokens_before).unwrap_or(u32::MAX),
134            tokens_after: u32::try_from(tokens_after).unwrap_or(u32::MAX),
135        })
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use std::time::Duration;
142
143    use zeph_llm::any::AnyProvider;
144    use zeph_llm::mock::MockProvider;
145
146    use super::*;
147    use crate::event::SessionEventEnvelope;
148
149    struct WordCountTokenCounter;
150    impl zeph_context::summarization::MessageTokenCounter for WordCountTokenCounter {
151        fn count_message_tokens(&self, msg: &Message) -> usize {
152            msg.content.split_whitespace().count().max(1)
153        }
154    }
155
156    fn deps() -> SummarizationDeps {
157        let summary_json = serde_json::json!({
158            "session_intent": "implement feature X",
159            "files_modified": ["src/lib.rs"],
160            "decisions_made": ["used approach A"],
161            "open_questions": [],
162            "next_steps": ["write tests"],
163        })
164        .to_string();
165        SummarizationDeps {
166            provider: AnyProvider::Mock(MockProvider::with_responses(vec![summary_json])),
167            llm_timeout: Duration::from_secs(5),
168            token_counter: std::sync::Arc::new(WordCountTokenCounter),
169            structured_summaries: true,
170            on_anchored_summary: None,
171        }
172    }
173
174    fn envelope(seq: u64, kind: SessionEvent) -> SessionEventEnvelope {
175        SessionEventEnvelope::new(seq, None, None, kind)
176    }
177
178    fn user_msg(seq: u64, text: &str) -> SessionEventEnvelope {
179        envelope(
180            seq,
181            SessionEvent::UserMessage {
182                text: text.to_owned(),
183                image_refs: vec![],
184            },
185        )
186    }
187
188    fn assistant_msg(seq: u64, text: &str) -> SessionEventEnvelope {
189        envelope(
190            seq,
191            SessionEvent::AssistantMessage {
192                parts: vec![MessagePart::Text {
193                    text: text.to_owned(),
194                }],
195            },
196        )
197    }
198
199    #[tokio::test]
200    async fn should_condense_respects_threshold_and_keep_recent() {
201        let condenser = LlmCondenser::new(deps(), 0.8, 4);
202        let mut state = ReconstructedState::default();
203        assert!(
204            !condenser.should_condense(&state, 0.9).await,
205            "too few messages"
206        );
207
208        state.messages = vec![
209            Message::from_legacy(Role::User, "a"),
210            Message::from_legacy(Role::User, "b"),
211            Message::from_legacy(Role::User, "c"),
212            Message::from_legacy(Role::User, "d"),
213            Message::from_legacy(Role::User, "e"),
214        ];
215        assert!(
216            !condenser.should_condense(&state, 0.5).await,
217            "below threshold"
218        );
219        assert!(
220            condenser.should_condense(&state, 0.8).await,
221            "at threshold, enough messages"
222        );
223    }
224
225    #[tokio::test]
226    async fn condense_rejects_when_not_enough_events() {
227        let condenser = LlmCondenser::new(deps(), 0.8, 4);
228        let events = vec![user_msg(0, "a"), assistant_msg(1, "b")];
229        let err = condenser.condense(&events, 0).await.unwrap_err();
230        assert!(matches!(err, SessionError::CondensationOverlap(_)));
231    }
232
233    #[tokio::test]
234    async fn condense_computes_replaced_range_keeping_recent_tail() {
235        let condenser = LlmCondenser::new(deps(), 0.8, 1);
236        // seq 0 is conventionally `SessionStarted` (never condensable — `validate_non_overlap`
237        // treats `last_condensed_seq=0` as "nothing condensed yet", so a range cannot start at
238        // seq 0); real logs always have a non-message event there, so start message seqs at 1.
239        let events = vec![
240            user_msg(1, "first question"),
241            assistant_msg(2, "first answer"),
242            user_msg(3, "second question"),
243        ];
244        // keep_recent=1 keeps the last message-starting event (seq 3); condenses [1, 2].
245        let result = condenser.condense(&events, 0).await.unwrap();
246        assert_eq!(result.replaced_range, (1, 2));
247        assert!(result.tokens_before > 0);
248    }
249
250    /// N2 regression (impl-critic re-verify finding): a second condensation on a *growing* log
251    /// — the full event history so far, not a pre-sliced tail, matching how
252    /// `maybe_condense_on_resume` actually calls this in production — must succeed and cover a
253    /// range strictly after the first condensation's, not re-attempt the already-condensed
254    /// range and fail `validate_non_overlap`. Two fresh `LlmCondenser` instances (matching
255    /// production: a new condenser is built per resume) each carry one mock LLM response.
256    #[tokio::test]
257    async fn condense_twice_on_growing_log_advances_past_prior_range() {
258        let first_condenser = LlmCondenser::new(deps(), 0.8, 1);
259        let mut events = vec![
260            user_msg(1, "q1"),
261            assistant_msg(2, "a1"),
262            user_msg(3, "q2"),
263            assistant_msg(4, "a2"),
264            user_msg(5, "q3"),
265            assistant_msg(6, "a3"),
266            user_msg(7, "q4"),
267        ];
268        let first_result = first_condenser.condense(&events, 0).await.unwrap();
269        assert_eq!(first_result.replaced_range, (1, 6));
270
271        // The session keeps growing after the first condensation — a resume some turns later
272        // sees the FULL log (seq 1..10), not just the tail since last_condensed_seq.
273        events.push(assistant_msg(8, "a4"));
274        events.push(user_msg(9, "q5"));
275        events.push(assistant_msg(10, "a5"));
276
277        let second_condenser = LlmCondenser::new(deps(), 0.8, 1);
278        let second_result = second_condenser
279            .condense(&events, first_result.replaced_range.1)
280            .await
281            .expect("second condensation on a growing log must not re-attempt the first's range");
282        assert_eq!(
283            second_result.replaced_range,
284            (7, 9),
285            "second condensation must start strictly after the first's replaced_range.1"
286        );
287    }
288}