entelix_agents/
summarizer.rs1use 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
18const 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
26pub 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 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 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 #[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
83fn 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 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}