Skip to main content

polyc_agent/
llm_summarizer.rs

1//! LLM-backed anchored-iterative [`Summarizer`].
2//!
3//! The agent crate's [`StubSummarizer`](crate::StubSummarizer) keeps the
4//! summarization data path live without a provider. This module is the
5//! one-trait swap-in that lands actual compression on long conversations.
6//!
7//! # Design
8//!
9//! Anchored iterative summarization (cf. Factory's 36k-message engineering-
10//! session eval): each compaction *merges* into the prior summary rather than
11//! re-summarizing the whole transcript from scratch. The prior summary is the
12//! persistent state that survives every subsequent compaction; the new
13//! transcript chunk is the delta. This preserves identifiers, commitments,
14//! decisions and errors across compactions instead of losing them as the
15//! window slides past them.
16//!
17//! # Failure mode
18//!
19//! Provider errors and missing-output are handled fail-soft: the summarizer
20//! returns the existing `prior_summary` unchanged and logs a `warn`. The next
21//! compaction will retry on the new transcript, and crucially the anchor is
22//! never lost. If we returned an empty string on failure the next
23//! `reconstruct_history` would still find the *previous* `summary:{uuid}`
24//! event in the journal — but a subsequent successful compaction would then
25//! anchor against a stale prior, so failing soft to the anchor is the safe
26//! shape.
27
28use 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
38/// System prompt used by [`LlmSummarizer`].
39///
40/// Pinned in source (not a config knob) so on-disk summaries are reproducible:
41/// the prompt that produced a given summary is the prompt encoded in the
42/// shipped binary at that revision. If we ever need to evolve this we'll bump
43/// it explicitly and accept that older summaries were produced under a prior
44/// prompt — that's a feature, not a bug, for the eval story.
45const 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
55/// Provider-backed anchored-iterative [`Summarizer`].
56///
57/// Wraps any [`LlmProvider`] behind the trait the control plane already
58/// consumes. The control plane builds one of these from whatever provider it
59/// instantiated for turns and injects it via `AgentSvc::with_summarizer`.
60///
61/// # Fields
62///
63/// - `provider` — the same trait the turn loop uses; sharing one `Arc` keeps
64///   pooled connections / auth state hot across turns *and* summarizations.
65/// - `model` — kept separate from the turn's model so production can point
66///   summarization at a cheaper / smaller model (e.g. flash-lite vs flash)
67///   without coupling the two upgrade paths.
68/// - `max_output_tokens` — cap on the generated summary. The prompt asks for
69///   "≤500 words" but the provider is the final guard; the cap is here to
70///   protect against a runaway provider regardless of the prompt.
71pub struct LlmSummarizer<P: ?Sized> {
72    /// The provider used to run the summarization completion.
73    provider: Arc<P>,
74    /// Model identifier sent to the provider for summarization calls.
75    model: String,
76    /// Hard cap on output tokens; the prompt also asks for ≤500 words.
77    max_output_tokens: u64,
78}
79
80impl<P: ?Sized> LlmSummarizer<P> {
81    /// Build a new [`LlmSummarizer`] over an existing provider.
82    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
91/// Per-tool-call byte budgets for the summarizer transcript. Generous enough to
92/// keep the identifiers, URLs, statuses and errors a summary must preserve, yet
93/// bounded so one fat payload can't dominate the chunk fed to the (small)
94/// summary model. Results carry the durable facts, so they get the larger
95/// budget; args (inputs) are usually short and reconstructable from context.
96/// Both stay well under the upstream 16 KiB per-tool-result cap in
97/// [`crate`]'s tool loop, which already bounds what reaches history.
98const TOOL_ARGS_CLIP_BYTES: usize = 1_024;
99const TOOL_RESULT_CLIP_BYTES: usize = 4_096;
100
101/// Clip an embedded JSON blob so one fat tool payload can't dominate the
102/// transcript (or be parroted wholesale by a cheap summarizer). Truncates on a
103/// UTF-8 char boundary at or below `max_bytes` in a single pass and notes how
104/// many bytes were dropped. Mirrors the char-boundary clip the control plane
105/// uses in `compaction_preview`.
106fn clip(s: &str, max_bytes: usize) -> String {
107    if s.len() <= max_bytes {
108        return s.to_owned();
109    }
110    // Largest char boundary <= max_bytes (`str::floor_char_boundary` is still
111    // unstable, so walk back the few bytes by hand).
112    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
119/// Render a transcript slice into the user-message body — one `role: text`
120/// line per message. Non-text content is rendered as a stable marker so the
121/// model sees the call/result happened without us fabricating its content.
122fn 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            // `Role` is `#[non_exhaustive]`; default any future variant to
130            // `user` so the model still sees the message rather than us
131            // refusing to render it.
132            _ => "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                    // Keep the call id so the summarizer can pair a result to
152                    // its call when several tool calls interleave in one folded
153                    // turn (parallel tool use). It is a plain id, not a marker.
154                    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                // `Content` is `#[non_exhaustive]`; if a future variant lands
166                // (audio, video, …) we render a placeholder rather than
167                // refusing to summarize and stalling the journal.
168                _ => {
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        // Empty-input shortcut: nothing to compress and no anchor to preserve.
193        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        // Cap output to bound the journal write and protect against runaway
213        // providers. `max_tokens` is u32 on the request; saturate the cast.
214        req.max_tokens = Some(u32::try_from(self.max_output_tokens).unwrap_or(u32::MAX));
215        // Low temperature for compaction: summarization is a deterministic
216        // rewriting task, not a creative one.
217        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    /// Provider that returns a fixed canned text. Used to verify the
254    /// summarizer wires through provider → collect_turn → string.
255    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    /// Provider that always returns a pre-stream error. Used to verify the
288    /// fail-soft path returns `prior_summary` unchanged.
289    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        // The provider saw a request shaped as expected: a system prompt,
313        // one user message, and the configured model + cap.
314        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        // The rendered user message embeds both PRIOR_SUMMARY and TRANSCRIPT.
320        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        // No provider call should be made when both inputs are empty.
361        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        // A provider that yields no text deltas (just stop). Fail-soft keeps
369        // the prior anchor rather than overwriting it with "".
370        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        // An args payload over the args budget must be clipped and rendered as
390        // prose, with no `[tool_call …]` bracket-DSL for a cheap summarizer to
391        // echo back verbatim.
392        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        // A result over the result budget is clipped too, rendered as prose,
403        // never the `[tool_result …]` bracket form, and keeps the call id so a
404        // result can be paired to its call.
405        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        // A short result is preserved verbatim (under budget → no clip marker),
416        // so identifiers smaller than the budget are never lost.
417        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        // A multibyte string clipped at a byte budget that lands mid-char must
428        // back off to a valid boundary (no panic) and report bytes dropped.
429        let s = "é".repeat(100); // 2 bytes each = 200 bytes
430        let out = clip(&s, 5); // 5 is not a char boundary for 'é' pairs
431        assert!(out.contains("bytes omitted"));
432        assert!(out.starts_with("éé")); // 4 bytes kept, boundary respected
433    }
434}