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.";
52
53/// Provider-backed anchored-iterative [`Summarizer`].
54///
55/// Wraps any [`LlmProvider`] behind the trait the control plane already
56/// consumes. The control plane builds one of these from whatever provider it
57/// instantiated for turns and injects it via `AgentSvc::with_summarizer`.
58///
59/// # Fields
60///
61/// - `provider` — the same trait the turn loop uses; sharing one `Arc` keeps
62///   pooled connections / auth state hot across turns *and* summarizations.
63/// - `model` — kept separate from the turn's model so production can point
64///   summarization at a cheaper / smaller model (e.g. flash-lite vs flash)
65///   without coupling the two upgrade paths.
66/// - `max_output_tokens` — cap on the generated summary. The prompt asks for
67///   "≤500 words" but the provider is the final guard; the cap is here to
68///   protect against a runaway provider regardless of the prompt.
69pub struct LlmSummarizer<P: ?Sized> {
70    /// The provider used to run the summarization completion.
71    provider: Arc<P>,
72    /// Model identifier sent to the provider for summarization calls.
73    model: String,
74    /// Hard cap on output tokens; the prompt also asks for ≤500 words.
75    max_output_tokens: u64,
76}
77
78impl<P: ?Sized> LlmSummarizer<P> {
79    /// Build a new [`LlmSummarizer`] over an existing provider.
80    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
89/// Render a transcript slice into the user-message body — one `role: text`
90/// line per message. Non-text content is rendered as a stable marker so the
91/// model sees the call/result happened without us fabricating its content.
92fn 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            // `Role` is `#[non_exhaustive]`; default any future variant to
100            // `user` so the model still sees the message rather than us
101            // refusing to render it.
102            _ => "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                // `Content` is `#[non_exhaustive]`; if a future variant lands
133                // (audio, video, …) we render a placeholder rather than
134                // refusing to summarize and stalling the journal.
135                _ => {
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        // Empty-input shortcut: nothing to compress and no anchor to preserve.
160        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        // Cap output to bound the journal write and protect against runaway
180        // providers. `max_tokens` is u32 on the request; saturate the cast.
181        req.max_tokens = Some(u32::try_from(self.max_output_tokens).unwrap_or(u32::MAX));
182        // Low temperature for compaction: summarization is a deterministic
183        // rewriting task, not a creative one.
184        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    /// Provider that returns a fixed canned text. Used to verify the
221    /// summarizer wires through provider → collect_turn → string.
222    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    /// Provider that always returns a pre-stream error. Used to verify the
255    /// fail-soft path returns `prior_summary` unchanged.
256    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        // The provider saw a request shaped as expected: a system prompt,
280        // one user message, and the configured model + cap.
281        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        // The rendered user message embeds both PRIOR_SUMMARY and TRANSCRIPT.
287        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        // No provider call should be made when both inputs are empty.
328        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        // A provider that yields no text deltas (just stop). Fail-soft keeps
336        // the prior anchor rather than overwriting it with "".
337        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}