1use std::sync::Arc;
29
30use async_trait::async_trait;
31use polyc_llm::{
32 CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role,
33 turn::collect_turn,
34};
35
36use crate::Summarizer;
37
38const SYSTEM_PROMPT: &str = "You compress agent transcripts for long-running conversations. \
46You receive (a) a PRIOR_SUMMARY representing the conversation so far, and \
47(b) a TRANSCRIPT chunk that just happened. \
48Produce a NEW_SUMMARY that fully replaces PRIOR_SUMMARY going forward: it \
49must preserve every commitment, identifier, decision, error, and unresolved \
50question from PRIOR_SUMMARY, then merge in the new content from TRANSCRIPT. \
51Be terse. No preamble. Maximum 500 words. Do not invent facts.";
52
53pub struct LlmSummarizer<P: ?Sized> {
70 provider: Arc<P>,
72 model: String,
74 max_output_tokens: u64,
76}
77
78impl<P: ?Sized> LlmSummarizer<P> {
79 pub fn new(provider: Arc<P>, model: impl Into<String>, max_output_tokens: u64) -> Self {
81 Self {
82 provider,
83 model: model.into(),
84 max_output_tokens,
85 }
86 }
87}
88
89fn render_transcript(transcript: &[LlmMessage]) -> String {
93 let mut s = String::new();
94 for msg in transcript {
95 let role = match msg.role {
96 Role::Assistant => "assistant",
97 Role::Tool => "tool",
98 Role::System => "system",
99 _ => "user",
103 };
104 for content in &msg.content {
105 match content {
106 LlmContent::Text(t) => {
107 s.push_str(role);
108 s.push_str(": ");
109 s.push_str(t);
110 s.push('\n');
111 }
112 LlmContent::ToolUse(tc) => {
113 s.push_str(role);
114 s.push_str(": [tool_call name=");
115 s.push_str(&tc.name);
116 s.push_str(" args=");
117 s.push_str(&tc.args_json);
118 s.push_str("]\n");
119 }
120 LlmContent::ToolResult(tr) => {
121 s.push_str(role);
122 s.push_str(": [tool_result for=");
123 s.push_str(&tr.tool_call_id);
124 s.push_str(" body=");
125 s.push_str(&tr.result_json);
126 s.push_str("]\n");
127 }
128 LlmContent::Image(_) => {
129 s.push_str(role);
130 s.push_str(": [image]\n");
131 }
132 _ => {
136 s.push_str(role);
137 s.push_str(": [unknown]\n");
138 }
139 }
140 }
141 }
142 s
143}
144
145#[async_trait]
146impl<P> Summarizer for LlmSummarizer<P>
147where
148 P: LlmProvider + Send + Sync + ?Sized,
149{
150 #[tracing::instrument(
151 skip_all,
152 fields(
153 model = %self.model,
154 transcript_messages = transcript.len(),
155 prior_summary_len = prior_summary.len(),
156 ),
157 )]
158 async fn summarize(&self, prior_summary: &str, transcript: &[LlmMessage]) -> String {
159 if transcript.is_empty() && prior_summary.is_empty() {
161 return String::new();
162 }
163
164 let prior_block = if prior_summary.is_empty() {
165 "(none — first compaction)".to_owned()
166 } else {
167 prior_summary.to_owned()
168 };
169 let transcript_block = if transcript.is_empty() {
170 "(empty)".to_owned()
171 } else {
172 render_transcript(transcript)
173 };
174 let user_text = format!("PRIOR_SUMMARY:\n{prior_block}\n\nTRANSCRIPT:\n{transcript_block}");
175
176 let mut req = CompletionRequest::new(&self.model);
177 req.system = Some(SYSTEM_PROMPT.to_owned());
178 req.messages.push(LlmMessage::user(user_text));
179 req.max_tokens = Some(u32::try_from(self.max_output_tokens).unwrap_or(u32::MAX));
182 req.temperature = Some(0.2);
185
186 match self.provider.complete(req).await {
187 Ok(stream) => match collect_turn(stream).await {
188 Ok(out) => {
189 let trimmed = out.text.trim();
190 if trimmed.is_empty() {
191 tracing::warn!("summarizer received empty output; keeping prior summary");
192 prior_summary.to_owned()
193 } else {
194 trimmed.to_owned()
195 }
196 }
197 Err(err) => {
198 tracing::warn!(error = %err, "summarizer stream error; keeping prior summary");
199 prior_summary.to_owned()
200 }
201 },
202 Err(err) => {
203 tracing::warn!(error = %err, "summarizer provider error; keeping prior summary");
204 prior_summary.to_owned()
205 }
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
213
214 use super::*;
215 use async_trait::async_trait;
216 use futures::{StreamExt, stream};
217 use polyc_llm::{Chunk, CompletionRequest, LlmProvider, StopReason, error::DummyError};
218 use std::sync::Mutex;
219
220 struct CannedProvider {
223 text: String,
224 seen: Mutex<Option<CompletionRequest>>,
225 }
226
227 impl CannedProvider {
228 fn new(text: &str) -> Self {
229 Self {
230 text: text.to_owned(),
231 seen: Mutex::new(None),
232 }
233 }
234 }
235
236 #[async_trait]
237 impl LlmProvider for CannedProvider {
238 type Error = DummyError;
239
240 async fn complete(
241 &self,
242 req: CompletionRequest,
243 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
244 {
245 *self.seen.lock().unwrap() = Some(req);
246 let chunks = vec![
247 Ok(Chunk::text_delta(self.text.clone())),
248 Ok(Chunk::Stop(StopReason::EndTurn)),
249 ];
250 Ok(stream::iter(chunks).boxed())
251 }
252 }
253
254 struct ErroringProvider;
257
258 #[async_trait]
259 impl LlmProvider for ErroringProvider {
260 type Error = DummyError;
261
262 async fn complete(
263 &self,
264 _req: CompletionRequest,
265 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
266 {
267 Err(DummyError::Other("nope".to_owned()))
268 }
269 }
270
271 #[tokio::test]
272 async fn returns_provider_output_trimmed() {
273 let provider = Arc::new(CannedProvider::new(" the new summary "));
274 let summ = LlmSummarizer::new(provider.clone(), "test-model", 1024);
275 let prior = "prior anchor";
276 let transcript = vec![LlmMessage::user("hi"), LlmMessage::assistant("hello")];
277 let out = summ.summarize(prior, &transcript).await;
278 assert_eq!(out, "the new summary");
279 let seen = provider.seen.lock().unwrap().clone().expect("request seen");
282 assert_eq!(seen.model, "test-model");
283 assert!(seen.system.is_some_and(|s| s.contains("PRIOR_SUMMARY")));
284 assert_eq!(seen.messages.len(), 1);
285 assert_eq!(seen.max_tokens, Some(1024));
286 let user_text = match &seen.messages[0].content[0] {
288 LlmContent::Text(t) => t.clone(),
289 _ => panic!("expected text content"),
290 };
291 assert!(user_text.contains("PRIOR_SUMMARY:"));
292 assert!(user_text.contains("prior anchor"));
293 assert!(user_text.contains("TRANSCRIPT:"));
294 assert!(user_text.contains("user: hi"));
295 assert!(user_text.contains("assistant: hello"));
296 }
297
298 #[tokio::test]
299 async fn first_compaction_uses_none_marker() {
300 let provider = Arc::new(CannedProvider::new("first summary"));
301 let summ = LlmSummarizer::new(provider.clone(), "test-model", 256);
302 let out = summ
303 .summarize("", &[LlmMessage::user("a"), LlmMessage::assistant("b")])
304 .await;
305 assert_eq!(out, "first summary");
306 let seen = provider.seen.lock().unwrap().clone().expect("request seen");
307 let user_text = match &seen.messages[0].content[0] {
308 LlmContent::Text(t) => t.clone(),
309 _ => panic!("expected text content"),
310 };
311 assert!(user_text.contains("(none — first compaction)"));
312 }
313
314 #[tokio::test]
315 async fn provider_error_returns_prior_summary_unchanged() {
316 let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
317 let prior = "this is the anchor";
318 let out = summ.summarize(prior, &[LlmMessage::user("hi")]).await;
319 assert_eq!(
320 out, prior,
321 "fail-soft: prior summary survives provider errors"
322 );
323 }
324
325 #[tokio::test]
326 async fn empty_inputs_short_circuit() {
327 let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
329 let out = summ.summarize("", &[]).await;
330 assert!(out.is_empty());
331 }
332
333 #[tokio::test]
334 async fn empty_output_falls_back_to_prior() {
335 struct EmptyProvider;
338 #[async_trait]
339 impl LlmProvider for EmptyProvider {
340 type Error = DummyError;
341 async fn complete(
342 &self,
343 _req: CompletionRequest,
344 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
345 {
346 Ok(stream::iter(vec![Ok(Chunk::Stop(StopReason::EndTurn))]).boxed())
347 }
348 }
349 let summ = LlmSummarizer::new(Arc::new(EmptyProvider), "test-model", 256);
350 let out = summ.summarize("keep me", &[LlmMessage::user("x")]).await;
351 assert_eq!(out, "keep me");
352 }
353}