Skip to main content

entelix_agents/
summarizer.rs

1//! `RunnableToSummarizerAdapter` — adapt any `Runnable<Vec<Message>, Message>`
2//! into a [`entelix_memory::Summarizer`] suitable for plugging into
3//! [`entelix_memory::ConsolidatingBufferMemory`].
4//!
5//! Lives here (in `entelix-agents`) rather than in `entelix-memory`
6//! so the memory crate stays decoupled from the runnable abstraction.
7//! Concrete LLM-driven summarisation belongs alongside the agent
8//! recipes that use it.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use entelix_core::ir::{ContentPart, Message};
14use entelix_core::{ExecutionContext, Result};
15use entelix_memory::Summarizer;
16use entelix_runnable::Runnable;
17
18/// Default system instruction prepended to the buffer when no
19/// explicit prompt is supplied.
20const DEFAULT_SUMMARY_SYSTEM_PROMPT: &str = "You are a summarisation assistant. Compress the \
21                                              conversation that follows into a concise running \
22                                              summary that preserves user intent, decisions, \
23                                              and outstanding questions. Reply with the summary \
24                                              text only — no preamble, no formatting markers.";
25
26/// [`Summarizer`] that delegates to any
27/// `Runnable<Vec<Message>, Message>` — the same shape as a chat
28/// model. Prepends a configurable system instruction so the model
29/// understands the task even when the buffer itself contains only
30/// raw user/assistant turns.
31pub struct RunnableToSummarizerAdapter<R> {
32    runnable: Arc<R>,
33    system_prompt: String,
34}
35
36impl<R> RunnableToSummarizerAdapter<R>
37where
38    R: Runnable<Vec<Message>, Message> + 'static,
39{
40    /// Build a summariser using `runnable` and the default system
41    /// instruction.
42    pub fn new(runnable: R) -> Self {
43        Self {
44            runnable: Arc::new(runnable),
45            system_prompt: DEFAULT_SUMMARY_SYSTEM_PROMPT.to_owned(),
46        }
47    }
48
49    /// Build a summariser using an `Arc`-wrapped runnable. Useful
50    /// when the same model serves multiple memories or is shared
51    /// with other parts of the agent.
52    pub fn from_arc(runnable: Arc<R>) -> Self {
53        Self {
54            runnable,
55            system_prompt: DEFAULT_SUMMARY_SYSTEM_PROMPT.to_owned(),
56        }
57    }
58
59    /// Override the system instruction. Use to inject domain-specific
60    /// summary guidance ("preserve transaction IDs verbatim"; "always
61    /// summarise in the user's preferred language").
62    #[must_use]
63    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
64        self.system_prompt = prompt.into();
65        self
66    }
67}
68
69#[async_trait]
70impl<R> Summarizer for RunnableToSummarizerAdapter<R>
71where
72    R: Runnable<Vec<Message>, Message> + 'static,
73{
74    async fn summarize(&self, messages: Vec<Message>, ctx: &ExecutionContext) -> Result<String> {
75        let mut prompt = Vec::with_capacity(messages.len() + 1);
76        prompt.push(Message::system(self.system_prompt.clone()));
77        prompt.extend(messages);
78        let response = self.runnable.invoke(prompt, ctx).await?;
79        Ok(extract_text(&response))
80    }
81}
82
83/// Concatenate every `ContentPart::Text` payload in `message`
84/// preserving order. Non-text parts are skipped — the consolidator
85/// only needs the textual summary.
86fn extract_text(message: &Message) -> String {
87    let mut out = String::new();
88    for part in &message.content {
89        if let ContentPart::Text { text, .. } = part {
90            if !out.is_empty() {
91                out.push(' ');
92            }
93            out.push_str(text);
94        }
95    }
96    out
97}
98
99#[cfg(test)]
100#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
101mod tests {
102    use super::*;
103    use entelix_runnable::RunnableLambda;
104
105    #[tokio::test]
106    async fn runnable_summariser_extracts_text_from_response() {
107        let runnable = RunnableLambda::new(|_msgs: Vec<Message>, _ctx| async move {
108            Ok::<_, _>(Message::assistant("compressed summary"))
109        });
110        let summariser = RunnableToSummarizerAdapter::new(runnable);
111        let ctx = ExecutionContext::new();
112        let out = summariser
113            .summarize(vec![Message::user("hi"), Message::assistant("hello")], &ctx)
114            .await
115            .unwrap();
116        assert_eq!(out, "compressed summary");
117    }
118
119    #[tokio::test]
120    async fn runnable_summariser_prepends_system_prompt() {
121        // Capture the prompt the runnable receives — verify the
122        // system instruction is present at index 0.
123        use std::sync::Mutex;
124        let captured: Arc<Mutex<Vec<Message>>> = Arc::new(Mutex::new(Vec::new()));
125        let captured_inner = Arc::clone(&captured);
126        let runnable = RunnableLambda::new(move |msgs: Vec<Message>, _ctx| {
127            let captured = Arc::clone(&captured_inner);
128            async move {
129                *captured.lock().unwrap() = msgs;
130                Ok::<_, _>(Message::assistant("ok"))
131            }
132        });
133        let summariser =
134            RunnableToSummarizerAdapter::new(runnable).with_system_prompt("custom system prompt");
135        let _ = summariser
136            .summarize(vec![Message::user("hi")], &ExecutionContext::new())
137            .await
138            .unwrap();
139        let prompt = captured.lock().unwrap().clone();
140        assert_eq!(prompt.len(), 2);
141        assert_eq!(prompt[0].role, entelix_core::ir::Role::System);
142        if let ContentPart::Text { text, .. } = &prompt[0].content[0] {
143            assert_eq!(text, "custom system prompt");
144        } else {
145            panic!("expected Text part");
146        }
147    }
148}