Skip to main content

zeph_memory/semantic/
summarization.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
5
6use super::{KEY_FACTS_COLLECTION, SemanticMemory};
7use crate::embedding_store::MessageKind;
8use crate::error::MemoryError;
9use crate::types::{ConversationId, MessageId};
10use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
11
12#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
13pub struct StructuredSummary {
14    pub summary: String,
15    pub key_facts: Vec<String>,
16    pub entities: Vec<String>,
17}
18
19#[derive(Debug, Clone)]
20pub struct Summary {
21    pub id: i64,
22    pub conversation_id: ConversationId,
23    pub content: String,
24    /// `None` for session-level summaries (e.g. shutdown summaries) with no tracked message range.
25    pub first_message_id: Option<MessageId>,
26    /// `None` for session-level summaries (e.g. shutdown summaries) with no tracked message range.
27    pub last_message_id: Option<MessageId>,
28    pub token_estimate: i64,
29}
30
31/// Outcome of a successful [`SemanticMemory::summarize`] call.
32#[derive(Debug, Clone, Copy)]
33pub struct SummarizeOutcome {
34    /// Row id of the newly created summary.
35    pub summary_id: i64,
36    /// Number of messages actually folded into this summary (the size of the
37    /// unsummarized range consumed, not the `message_count` argument requested).
38    pub messages_folded: usize,
39}
40
41#[must_use]
42pub fn build_summarization_prompt(messages: &[(MessageId, String, String)]) -> String {
43    let mut prompt = String::from(
44        "Summarize the following conversation. Extract key facts, decisions, entities, \
45         and context needed to continue the conversation.\n\n\
46         Respond in JSON with fields: summary (string), key_facts (list of strings), \
47         entities (list of strings).\n\nConversation:\n",
48    );
49
50    for (_, role, content) in messages {
51        prompt.push_str(role);
52        prompt.push_str(": ");
53        prompt.push_str(content);
54        prompt.push('\n');
55    }
56
57    prompt
58}
59
60impl SemanticMemory {
61    /// Load all summaries for a conversation.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the query fails.
66    pub async fn load_summaries(
67        &self,
68        conversation_id: ConversationId,
69    ) -> Result<Vec<Summary>, MemoryError> {
70        let rows = self.sqlite.load_summaries(conversation_id).await?;
71        let summaries = rows
72            .into_iter()
73            .map(
74                |(
75                    id,
76                    conversation_id,
77                    content,
78                    first_message_id,
79                    last_message_id,
80                    token_estimate,
81                )| {
82                    Summary {
83                        id,
84                        conversation_id,
85                        content,
86                        first_message_id,
87                        last_message_id,
88                        token_estimate,
89                    }
90                },
91            )
92            .collect();
93        Ok(summaries)
94    }
95
96    /// Generate a summary of the oldest unsummarized messages.
97    ///
98    /// Returns `Ok(None)` if there are not enough messages to summarize.
99    /// [`SummarizeOutcome::messages_folded`] reflects the actual number of messages folded
100    /// into the new summary, which may be less than `message_count` when fewer unsummarized
101    /// messages exist.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if LLM call or database operation fails.
106    #[tracing::instrument(name = "memory.summarize", skip_all, fields(input_msgs = %message_count, output_len = tracing::field::Empty))]
107    pub async fn summarize(
108        &self,
109        conversation_id: ConversationId,
110        message_count: usize,
111    ) -> Result<Option<SummarizeOutcome>, MemoryError> {
112        let total = self.sqlite.count_messages(conversation_id).await?;
113
114        if total <= i64::try_from(message_count)? {
115            return Ok(None);
116        }
117
118        let after_id = self
119            .sqlite
120            .latest_summary_last_message_id(conversation_id)
121            .await?
122            .unwrap_or(MessageId(0));
123
124        let messages = self
125            .sqlite
126            .load_messages_range(conversation_id, after_id, message_count)
127            .await?;
128
129        if messages.is_empty() {
130            return Ok(None);
131        }
132
133        let messages_folded = messages.len();
134        let prompt = build_summarization_prompt(&messages);
135        let chat_messages = vec![Message {
136            role: Role::User,
137            content: prompt,
138            parts: vec![],
139            metadata: MessageMetadata::default(),
140        }];
141
142        let structured = self.call_summarization_llm(&chat_messages).await?;
143        let summary_text = &structured.summary;
144
145        let token_estimate = i64::try_from(self.token_counter.count_tokens(summary_text))?;
146        let first_message_id = messages[0].0;
147        let last_message_id = messages[messages.len() - 1].0;
148
149        let summary_id = self
150            .sqlite
151            .save_summary(
152                conversation_id,
153                summary_text,
154                Some(first_message_id),
155                Some(last_message_id),
156                token_estimate,
157            )
158            .await?;
159
160        if let Some(qdrant) = &self.qdrant
161            && self.effective_embed_provider().supports_embeddings()
162        {
163            match tokio::time::timeout(
164                self.embed_timeout,
165                self.effective_embed_provider().embed(summary_text),
166            )
167            .await
168            {
169                Ok(Ok(vector)) => {
170                    if let Err(e) = qdrant.ensure_collection_for_vector(&vector).await {
171                        tracing::warn!("Failed to ensure Qdrant collection: {e:#}");
172                    } else if let Err(e) = qdrant
173                        .store(
174                            MessageId(summary_id),
175                            conversation_id,
176                            "system",
177                            vector,
178                            MessageKind::Summary,
179                            &self.embedding_model,
180                            0,
181                            // LLM-generated from already-persisted (and thus already
182                            // provenance-tagged) history — treated as trusted (issue #6490).
183                            Some("trusted"),
184                        )
185                        .await
186                    {
187                        tracing::warn!("Failed to embed summary: {e:#}");
188                    }
189                }
190                Ok(Err(e)) => {
191                    tracing::warn!("Failed to generate summary embedding: {e:#}");
192                }
193                Err(_) => {
194                    tracing::warn!("summarize: embed timed out for summary text — skipping store");
195                }
196            }
197        }
198
199        if !structured.key_facts.is_empty() {
200            self.store_key_facts(conversation_id, summary_id, &structured.key_facts)
201                .await;
202        }
203
204        Ok(Some(SummarizeOutcome {
205            summary_id,
206            messages_folded,
207        }))
208    }
209
210    /// Call the LLM to produce a [`StructuredSummary`], falling back to plain text on parse error.
211    ///
212    /// Both the structured and fallback calls are bounded by `summarization_llm_timeout_secs`.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`MemoryError::Timeout`] if the LLM exceeds the deadline, or
217    /// [`MemoryError::Llm`] if the provider returns an error.
218    async fn call_summarization_llm(
219        &self,
220        chat_messages: &[Message],
221    ) -> Result<StructuredSummary, MemoryError> {
222        let timeout_secs = self.summarization_llm_timeout_secs;
223        let timeout = std::time::Duration::from_secs(timeout_secs);
224        match tokio::time::timeout(
225            timeout,
226            self.provider
227                .chat_typed_erased::<StructuredSummary>(chat_messages),
228        )
229        .await
230        {
231            Ok(Ok(s)) => Ok(s),
232            Ok(Err(e)) => {
233                tracing::warn!(
234                    "structured summarization failed, falling back to plain text: {e:#}"
235                );
236                match tokio::time::timeout(timeout, self.provider.chat(chat_messages)).await {
237                    Ok(Ok(plain)) => Ok(StructuredSummary {
238                        summary: plain,
239                        key_facts: vec![],
240                        entities: vec![],
241                    }),
242                    Ok(Err(e)) => Err(MemoryError::Llm(e)),
243                    Err(_elapsed) => {
244                        tracing::warn!(
245                            "summarization: plain text fallback LLM call timed out after {timeout_secs}s"
246                        );
247                        Err(MemoryError::Timeout("LLM call timed out".into()))
248                    }
249                }
250            }
251            Err(_elapsed) => {
252                tracing::warn!(
253                    "summarization: structured LLM call timed out after {timeout_secs}s"
254                );
255                Err(MemoryError::Timeout("LLM call timed out".into()))
256            }
257        }
258    }
259
260    pub(super) async fn store_key_facts(
261        &self,
262        conversation_id: ConversationId,
263        source_summary_id: i64,
264        key_facts: &[String],
265    ) {
266        let Some(qdrant) = &self.qdrant else {
267            return;
268        };
269        if !self.effective_embed_provider().supports_embeddings() {
270            return;
271        }
272
273        // Filter out transient policy-decision facts that describe a blocked or denied action.
274        // These reflect the agent's state at a single point in time and must not be recalled
275        // as stable world facts in future turns — doing so causes the agent to skip valid calls.
276        let filtered: Vec<&str> = key_facts
277            .iter()
278            .filter(|f| !is_policy_decision_fact(f.as_str()))
279            .map(String::as_str)
280            .collect();
281
282        let Some(first_fact) = filtered.first().copied() else {
283            return;
284        };
285        let first_vector = match tokio::time::timeout(
286            self.embed_timeout,
287            self.effective_embed_provider().embed(first_fact),
288        )
289        .await
290        {
291            Ok(Ok(v)) => v,
292            Ok(Err(e)) => {
293                tracing::warn!("Failed to embed key fact: {e:#}");
294                return;
295            }
296            Err(_) => {
297                tracing::warn!("store_key_facts: embed timed out for first fact — skipping");
298                return;
299            }
300        };
301        if let Err(e) = qdrant
302            .ensure_named_collection_for_vector(KEY_FACTS_COLLECTION, &first_vector)
303            .await
304        {
305            tracing::warn!("Failed to ensure key_facts collection: {e:#}");
306            return;
307        }
308
309        let threshold = self.key_facts_dedup_threshold;
310        self.store_key_fact_if_unique(
311            qdrant,
312            conversation_id,
313            source_summary_id,
314            first_fact,
315            first_vector,
316            threshold,
317        )
318        .await;
319
320        for fact in filtered[1..].iter().copied() {
321            match tokio::time::timeout(
322                self.embed_timeout,
323                self.effective_embed_provider().embed(fact),
324            )
325            .await
326            {
327                Ok(Ok(vector)) => {
328                    self.store_key_fact_if_unique(
329                        qdrant,
330                        conversation_id,
331                        source_summary_id,
332                        fact,
333                        vector,
334                        threshold,
335                    )
336                    .await;
337                }
338                Ok(Err(e)) => {
339                    tracing::warn!("Failed to embed key fact: {e:#}");
340                }
341                Err(_) => {
342                    tracing::warn!("store_key_facts: embed timed out for fact — skipping");
343                }
344            }
345        }
346    }
347
348    async fn store_key_fact_if_unique(
349        &self,
350        qdrant: &crate::embedding_store::EmbeddingStore,
351        conversation_id: ConversationId,
352        source_summary_id: i64,
353        fact: &str,
354        vector: Vec<f32>,
355        threshold: f32,
356    ) {
357        // Scope the near-duplicate check to this conversation, matching the read-side filter in
358        // `search_key_facts`. An unscoped (global) dedup search would let a fact stored under one
359        // conversation silently suppress a near-identical fact for a different conversation, which
360        // is then unrecoverable from that other conversation's conversation-scoped search (#5732).
361        let dedup_filter = Some(VectorFilter {
362            must: vec![
363                FieldCondition {
364                    field: "conversation_id".into(),
365                    value: FieldValue::Integer(conversation_id.0),
366                },
367                FieldCondition {
368                    field: "db_instance_id".into(),
369                    value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
370                },
371            ],
372            must_not: vec![],
373        });
374        match qdrant
375            .search_collection(KEY_FACTS_COLLECTION, &vector, 1, dedup_filter)
376            .await
377        {
378            Ok(hits) if hits.first().is_some_and(|h| h.score >= threshold) => {
379                tracing::debug!(
380                    score = hits[0].score,
381                    threshold,
382                    "key-facts: skipping near-duplicate fact"
383                );
384                return;
385            }
386            Ok(_) => {}
387            Err(e) => {
388                tracing::warn!("key-facts: dedup search failed, storing anyway: {e:#}");
389            }
390        }
391
392        let payload = serde_json::json!({
393            "conversation_id": conversation_id.0,
394            "db_instance_id": qdrant.db_instance_id(),
395            "fact_text": fact,
396            "source_summary_id": source_summary_id,
397        });
398        if let Err(e) = qdrant
399            .store_to_collection(KEY_FACTS_COLLECTION, payload, vector)
400            .await
401        {
402            tracing::warn!("Failed to store key fact: {e:#}");
403        }
404    }
405
406    /// Search key facts extracted from conversation summaries.
407    ///
408    /// When `conversation_id` is `Some`, results are restricted to facts scoped to that
409    /// conversation; facts written without a `conversation_id` payload field (e.g. cross-session
410    /// episodic-consolidation facts, or points written before this scoping was introduced) will
411    /// not match. Pass `None` to search across all conversations.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if embedding or Qdrant search fails.
416    pub async fn search_key_facts(
417        &self,
418        query: &str,
419        limit: usize,
420        conversation_id: Option<ConversationId>,
421    ) -> Result<Vec<String>, MemoryError> {
422        let Some(qdrant) = &self.qdrant else {
423            tracing::debug!("key-facts: skipped, no vector store");
424            return Ok(Vec::new());
425        };
426        if !self.effective_embed_provider().supports_embeddings() {
427            tracing::debug!("key-facts: skipped, no embedding support");
428            return Ok(Vec::new());
429        }
430
431        let vector = match tokio::time::timeout(
432            self.embed_timeout,
433            self.effective_embed_provider().embed(query),
434        )
435        .await
436        {
437            Ok(Ok(v)) => v,
438            Ok(Err(e)) => return Err(e.into()),
439            Err(_) => {
440                tracing::warn!("search_key_facts: embed timed out, returning empty results");
441                return Ok(Vec::new());
442            }
443        };
444        qdrant
445            .ensure_named_collection_for_vector(KEY_FACTS_COLLECTION, &vector)
446            .await?;
447
448        let filter = conversation_id.map(|cid| VectorFilter {
449            must: vec![
450                FieldCondition {
451                    field: "conversation_id".into(),
452                    value: FieldValue::Integer(cid.0),
453                },
454                FieldCondition {
455                    field: "db_instance_id".into(),
456                    value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
457                },
458            ],
459            must_not: vec![],
460        });
461
462        let points = qdrant
463            .search_collection(KEY_FACTS_COLLECTION, &vector, limit, filter)
464            .await?;
465
466        tracing::debug!(
467            results = points.len(),
468            limit,
469            conversation_id = conversation_id.map(|c| c.0),
470            "key-facts: search complete"
471        );
472
473        let facts = points
474            .into_iter()
475            .filter_map(|p| p.payload.get("fact_text")?.as_str().map(String::from))
476            .collect();
477
478        Ok(facts)
479    }
480
481    /// Search a named document collection by semantic similarity.
482    ///
483    /// Returns up to `limit` scored vector points whose payloads contain ingested document chunks.
484    /// Returns an empty vec when Qdrant is unavailable, the collection does not exist,
485    /// or the provider does not support embeddings.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if embedding generation or Qdrant search fails.
490    pub async fn search_document_collection(
491        &self,
492        collection: &str,
493        query: &str,
494        limit: usize,
495    ) -> Result<Vec<crate::ScoredVectorPoint>, MemoryError> {
496        let Some(qdrant) = &self.qdrant else {
497            return Ok(Vec::new());
498        };
499        if !self.effective_embed_provider().supports_embeddings() {
500            return Ok(Vec::new());
501        }
502        if !qdrant.collection_exists(collection).await? {
503            return Ok(Vec::new());
504        }
505        let vector = match tokio::time::timeout(
506            self.embed_timeout,
507            self.effective_embed_provider().embed(query),
508        )
509        .await
510        {
511            Ok(Ok(v)) => v,
512            Ok(Err(e)) => return Err(e.into()),
513            Err(_) => {
514                tracing::warn!(
515                    "search_document_collection: embed timed out, returning empty results"
516                );
517                return Ok(Vec::new());
518            }
519        };
520        let results = qdrant
521            .search_collection(collection, &vector, limit, None)
522            .await?;
523
524        tracing::debug!(
525            results = results.len(),
526            limit,
527            collection,
528            "document-collection: search complete"
529        );
530
531        Ok(results)
532    }
533}
534
535/// Returns `true` when a fact string describes a transient policy or permission decision.
536///
537/// Facts like "reading /etc/passwd was blocked by utility policy" are snapshots of a
538/// single-turn enforcement state and must not be recalled as durable world knowledge.
539/// Storing them causes the agent to believe a tool is permanently unavailable.
540pub(crate) fn is_policy_decision_fact(fact: &str) -> bool {
541    const MARKERS: &[&str] = &[
542        "blocked",
543        "skipped",
544        "cannot access",
545        "security polic",
546        "utility polic",
547        "not allowed",
548        "permission denied",
549        "access denied",
550        "was denied",
551    ];
552    let lower = fact.to_lowercase();
553    MARKERS.iter().any(|m| lower.contains(m))
554}