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. \
52Describe tool calls and results in plain prose; never reproduce raw \
53[tool_call …] or [tool_result …] markers or verbatim JSON.";
54
55pub struct LlmSummarizer<P: ?Sized> {
72 provider: Arc<P>,
74 model: String,
76 max_output_tokens: u64,
78}
79
80impl<P: ?Sized> LlmSummarizer<P> {
81 pub fn new(provider: Arc<P>, model: impl Into<String>, max_output_tokens: u64) -> Self {
83 Self {
84 provider,
85 model: model.into(),
86 max_output_tokens,
87 }
88 }
89}
90
91const TOOL_ARGS_CLIP_BYTES: usize = 1_024;
99const TOOL_RESULT_CLIP_BYTES: usize = 4_096;
100
101fn clip(s: &str, max_bytes: usize) -> String {
107 if s.len() <= max_bytes {
108 return s.to_owned();
109 }
110 let mut end = max_bytes;
113 while end > 0 && !s.is_char_boundary(end) {
114 end -= 1;
115 }
116 format!("{}… ({} bytes omitted)", &s[..end], s.len() - end)
117}
118
119fn render_transcript(transcript: &[LlmMessage]) -> String {
123 let mut s = String::new();
124 for msg in transcript {
125 let role = match msg.role {
126 Role::Assistant => "assistant",
127 Role::Tool => "tool",
128 Role::System => "system",
129 _ => "user",
133 };
134 for content in &msg.content {
135 match content {
136 LlmContent::Text(t) => {
137 s.push_str(role);
138 s.push_str(": ");
139 s.push_str(t);
140 s.push('\n');
141 }
142 LlmContent::ToolUse(tc) => {
143 s.push_str(role);
144 s.push_str(": called tool ");
145 s.push_str(&tc.name);
146 s.push('(');
147 s.push_str(&clip(&tc.args_json, TOOL_ARGS_CLIP_BYTES));
148 s.push_str(")\n");
149 }
150 LlmContent::ToolResult(tr) => {
151 s.push_str(role);
155 s.push_str(": tool result for ");
156 s.push_str(&tr.tool_call_id);
157 s.push_str(" → ");
158 s.push_str(&clip(&tr.result_json, TOOL_RESULT_CLIP_BYTES));
159 s.push('\n');
160 }
161 LlmContent::Image(_) => {
162 s.push_str(role);
163 s.push_str(": [image]\n");
164 }
165 _ => {
169 s.push_str(role);
170 s.push_str(": [unknown]\n");
171 }
172 }
173 }
174 }
175 s
176}
177
178#[async_trait]
179impl<P> Summarizer for LlmSummarizer<P>
180where
181 P: LlmProvider + Send + Sync + ?Sized,
182{
183 #[tracing::instrument(
184 skip_all,
185 fields(
186 model = %self.model,
187 transcript_messages = transcript.len(),
188 prior_summary_len = prior_summary.len(),
189 ),
190 )]
191 async fn summarize(&self, prior_summary: &str, transcript: &[LlmMessage]) -> String {
192 if transcript.is_empty() && prior_summary.is_empty() {
194 return String::new();
195 }
196
197 let prior_block = if prior_summary.is_empty() {
198 "(none — first compaction)".to_owned()
199 } else {
200 prior_summary.to_owned()
201 };
202 let transcript_block = if transcript.is_empty() {
203 "(empty)".to_owned()
204 } else {
205 render_transcript(transcript)
206 };
207 let user_text = format!("PRIOR_SUMMARY:\n{prior_block}\n\nTRANSCRIPT:\n{transcript_block}");
208
209 let mut req = CompletionRequest::new(&self.model);
210 req.system = Some(SYSTEM_PROMPT.to_owned());
211 req.messages.push(LlmMessage::user(user_text));
212 req.max_tokens = Some(u32::try_from(self.max_output_tokens).unwrap_or(u32::MAX));
215 req.temperature = Some(0.2);
218
219 match self.provider.complete(req).await {
220 Ok(stream) => match collect_turn(stream).await {
221 Ok(out) => {
222 let trimmed = out.text.trim();
223 if trimmed.is_empty() {
224 tracing::warn!("summarizer received empty output; keeping prior summary");
225 prior_summary.to_owned()
226 } else {
227 trimmed.to_owned()
228 }
229 }
230 Err(err) => {
231 tracing::warn!(error = %err, "summarizer stream error; keeping prior summary");
232 prior_summary.to_owned()
233 }
234 },
235 Err(err) => {
236 tracing::warn!(error = %err, "summarizer provider error; keeping prior summary");
237 prior_summary.to_owned()
238 }
239 }
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
246
247 use super::*;
248 use async_trait::async_trait;
249 use futures::{StreamExt, stream};
250 use polyc_llm::{Chunk, CompletionRequest, LlmProvider, StopReason, error::DummyError};
251 use std::sync::Mutex;
252
253 struct CannedProvider {
256 text: String,
257 seen: Mutex<Option<CompletionRequest>>,
258 }
259
260 impl CannedProvider {
261 fn new(text: &str) -> Self {
262 Self {
263 text: text.to_owned(),
264 seen: Mutex::new(None),
265 }
266 }
267 }
268
269 #[async_trait]
270 impl LlmProvider for CannedProvider {
271 type Error = DummyError;
272
273 async fn complete(
274 &self,
275 req: CompletionRequest,
276 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
277 {
278 *self.seen.lock().unwrap() = Some(req);
279 let chunks = vec![
280 Ok(Chunk::text_delta(self.text.clone())),
281 Ok(Chunk::Stop(StopReason::EndTurn)),
282 ];
283 Ok(stream::iter(chunks).boxed())
284 }
285 }
286
287 struct ErroringProvider;
290
291 #[async_trait]
292 impl LlmProvider for ErroringProvider {
293 type Error = DummyError;
294
295 async fn complete(
296 &self,
297 _req: CompletionRequest,
298 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
299 {
300 Err(DummyError::Other("nope".to_owned()))
301 }
302 }
303
304 #[tokio::test]
305 async fn returns_provider_output_trimmed() {
306 let provider = Arc::new(CannedProvider::new(" the new summary "));
307 let summ = LlmSummarizer::new(provider.clone(), "test-model", 1024);
308 let prior = "prior anchor";
309 let transcript = vec![LlmMessage::user("hi"), LlmMessage::assistant("hello")];
310 let out = summ.summarize(prior, &transcript).await;
311 assert_eq!(out, "the new summary");
312 let seen = provider.seen.lock().unwrap().clone().expect("request seen");
315 assert_eq!(seen.model, "test-model");
316 assert!(seen.system.is_some_and(|s| s.contains("PRIOR_SUMMARY")));
317 assert_eq!(seen.messages.len(), 1);
318 assert_eq!(seen.max_tokens, Some(1024));
319 let user_text = match &seen.messages[0].content[0] {
321 LlmContent::Text(t) => t.clone(),
322 _ => panic!("expected text content"),
323 };
324 assert!(user_text.contains("PRIOR_SUMMARY:"));
325 assert!(user_text.contains("prior anchor"));
326 assert!(user_text.contains("TRANSCRIPT:"));
327 assert!(user_text.contains("user: hi"));
328 assert!(user_text.contains("assistant: hello"));
329 }
330
331 #[tokio::test]
332 async fn first_compaction_uses_none_marker() {
333 let provider = Arc::new(CannedProvider::new("first summary"));
334 let summ = LlmSummarizer::new(provider.clone(), "test-model", 256);
335 let out = summ
336 .summarize("", &[LlmMessage::user("a"), LlmMessage::assistant("b")])
337 .await;
338 assert_eq!(out, "first summary");
339 let seen = provider.seen.lock().unwrap().clone().expect("request seen");
340 let user_text = match &seen.messages[0].content[0] {
341 LlmContent::Text(t) => t.clone(),
342 _ => panic!("expected text content"),
343 };
344 assert!(user_text.contains("(none — first compaction)"));
345 }
346
347 #[tokio::test]
348 async fn provider_error_returns_prior_summary_unchanged() {
349 let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
350 let prior = "this is the anchor";
351 let out = summ.summarize(prior, &[LlmMessage::user("hi")]).await;
352 assert_eq!(
353 out, prior,
354 "fail-soft: prior summary survives provider errors"
355 );
356 }
357
358 #[tokio::test]
359 async fn empty_inputs_short_circuit() {
360 let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
362 let out = summ.summarize("", &[]).await;
363 assert!(out.is_empty());
364 }
365
366 #[tokio::test]
367 async fn empty_output_falls_back_to_prior() {
368 struct EmptyProvider;
371 #[async_trait]
372 impl LlmProvider for EmptyProvider {
373 type Error = DummyError;
374 async fn complete(
375 &self,
376 _req: CompletionRequest,
377 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
378 {
379 Ok(stream::iter(vec![Ok(Chunk::Stop(StopReason::EndTurn))]).boxed())
380 }
381 }
382 let summ = LlmSummarizer::new(Arc::new(EmptyProvider), "test-model", 256);
383 let out = summ.summarize("keep me", &[LlmMessage::user("x")]).await;
384 assert_eq!(out, "keep me");
385 }
386
387 #[test]
388 fn tool_calls_render_as_clipped_prose_not_bracket_dsl() {
389 let long_args = format!("{{\"q\":\"{}\"}}", "x".repeat(TOOL_ARGS_CLIP_BYTES));
393 let use_msg = LlmMessage {
394 role: Role::Assistant,
395 content: vec![LlmContent::tool_use("call-1", "search", long_args)],
396 };
397 let use_rendered = render_transcript(&[use_msg]);
398 assert!(!use_rendered.contains("[tool_call"));
399 assert!(use_rendered.contains("called tool search("));
400 assert!(use_rendered.contains("bytes omitted"));
401
402 let long_result = format!("{{\"id\":\"{}\"}}", "y".repeat(TOOL_RESULT_CLIP_BYTES));
406 let result_msg = LlmMessage {
407 role: Role::Tool,
408 content: vec![LlmContent::tool_result("call-1", long_result, false)],
409 };
410 let result_rendered = render_transcript(&[result_msg]);
411 assert!(result_rendered.contains("tool result for call-1 →"));
412 assert!(!result_rendered.contains("[tool_result"));
413 assert!(result_rendered.contains("bytes omitted"));
414
415 let short = render_transcript(&[LlmMessage {
418 role: Role::Tool,
419 content: vec![LlmContent::tool_result("call-2", "{\"ok\":true}", false)],
420 }]);
421 assert!(short.contains("tool result for call-2 → {\"ok\":true}"));
422 assert!(!short.contains("bytes omitted"));
423 }
424
425 #[test]
426 fn clip_truncates_on_char_boundary_without_panicking() {
427 let s = "é".repeat(100); let out = clip(&s, 5); assert!(out.contains("bytes omitted"));
432 assert!(out.starts_with("éé")); }
434}