use std::sync::Arc;
use async_trait::async_trait;
use polyc_llm::{
CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role,
turn::collect_turn,
};
use crate::Summarizer;
const SYSTEM_PROMPT: &str = "You compress agent transcripts for long-running conversations. \
You receive (a) a PRIOR_SUMMARY representing the conversation so far, and \
(b) a TRANSCRIPT chunk that just happened. \
Produce a NEW_SUMMARY that fully replaces PRIOR_SUMMARY going forward: it \
must preserve every commitment, identifier, decision, error, and unresolved \
question from PRIOR_SUMMARY, then merge in the new content from TRANSCRIPT. \
Be terse. No preamble. Maximum 500 words. Do not invent facts. \
Describe tool calls and results in plain prose; never reproduce raw \
[tool_call …] or [tool_result …] markers or verbatim JSON.";
pub struct LlmSummarizer<P: ?Sized> {
provider: Arc<P>,
model: String,
max_output_tokens: u64,
}
impl<P: ?Sized> LlmSummarizer<P> {
pub fn new(provider: Arc<P>, model: impl Into<String>, max_output_tokens: u64) -> Self {
Self {
provider,
model: model.into(),
max_output_tokens,
}
}
}
const TOOL_ARGS_CLIP_BYTES: usize = 1_024;
const TOOL_RESULT_CLIP_BYTES: usize = 4_096;
fn clip(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_owned();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes omitted)", &s[..end], s.len() - end)
}
fn render_transcript(transcript: &[LlmMessage]) -> String {
let mut s = String::new();
for msg in transcript {
let role = match msg.role {
Role::Assistant => "assistant",
Role::Tool => "tool",
Role::System => "system",
_ => "user",
};
for content in &msg.content {
match content {
LlmContent::Text(t) => {
s.push_str(role);
s.push_str(": ");
s.push_str(t);
s.push('\n');
}
LlmContent::ToolUse(tc) => {
s.push_str(role);
s.push_str(": called tool ");
s.push_str(&tc.name);
s.push('(');
s.push_str(&clip(&tc.args_json, TOOL_ARGS_CLIP_BYTES));
s.push_str(")\n");
}
LlmContent::ToolResult(tr) => {
s.push_str(role);
s.push_str(": tool result for ");
s.push_str(&tr.tool_call_id);
s.push_str(" → ");
s.push_str(&clip(&tr.result_json, TOOL_RESULT_CLIP_BYTES));
s.push('\n');
}
LlmContent::Image(_) => {
s.push_str(role);
s.push_str(": [image]\n");
}
_ => {
s.push_str(role);
s.push_str(": [unknown]\n");
}
}
}
}
s
}
#[async_trait]
impl<P> Summarizer for LlmSummarizer<P>
where
P: LlmProvider + Send + Sync + ?Sized,
{
#[tracing::instrument(
skip_all,
fields(
model = %self.model,
transcript_messages = transcript.len(),
prior_summary_len = prior_summary.len(),
),
)]
async fn summarize(&self, prior_summary: &str, transcript: &[LlmMessage]) -> String {
if transcript.is_empty() && prior_summary.is_empty() {
return String::new();
}
let prior_block = if prior_summary.is_empty() {
"(none — first compaction)".to_owned()
} else {
prior_summary.to_owned()
};
let transcript_block = if transcript.is_empty() {
"(empty)".to_owned()
} else {
render_transcript(transcript)
};
let user_text = format!("PRIOR_SUMMARY:\n{prior_block}\n\nTRANSCRIPT:\n{transcript_block}");
let mut req = CompletionRequest::new(&self.model);
req.system = Some(SYSTEM_PROMPT.to_owned());
req.messages.push(LlmMessage::user(user_text));
req.max_tokens = Some(u32::try_from(self.max_output_tokens).unwrap_or(u32::MAX));
req.temperature = Some(0.2);
match self.provider.complete(req).await {
Ok(stream) => match collect_turn(stream).await {
Ok(out) => {
let trimmed = out.text.trim();
if trimmed.is_empty() {
tracing::warn!("summarizer received empty output; keeping prior summary");
prior_summary.to_owned()
} else {
trimmed.to_owned()
}
}
Err(err) => {
tracing::warn!(error = %err, "summarizer stream error; keeping prior summary");
prior_summary.to_owned()
}
},
Err(err) => {
tracing::warn!(error = %err, "summarizer provider error; keeping prior summary");
prior_summary.to_owned()
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use async_trait::async_trait;
use futures::{StreamExt, stream};
use polyc_llm::{Chunk, CompletionRequest, LlmProvider, StopReason, error::DummyError};
use std::sync::Mutex;
struct CannedProvider {
text: String,
seen: Mutex<Option<CompletionRequest>>,
}
impl CannedProvider {
fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
seen: Mutex::new(None),
}
}
}
#[async_trait]
impl LlmProvider for CannedProvider {
type Error = DummyError;
async fn complete(
&self,
req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
*self.seen.lock().unwrap() = Some(req);
let chunks = vec![
Ok(Chunk::text_delta(self.text.clone())),
Ok(Chunk::Stop(StopReason::EndTurn)),
];
Ok(stream::iter(chunks).boxed())
}
}
struct ErroringProvider;
#[async_trait]
impl LlmProvider for ErroringProvider {
type Error = DummyError;
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
Err(DummyError::Other("nope".to_owned()))
}
}
#[tokio::test]
async fn returns_provider_output_trimmed() {
let provider = Arc::new(CannedProvider::new(" the new summary "));
let summ = LlmSummarizer::new(provider.clone(), "test-model", 1024);
let prior = "prior anchor";
let transcript = vec![LlmMessage::user("hi"), LlmMessage::assistant("hello")];
let out = summ.summarize(prior, &transcript).await;
assert_eq!(out, "the new summary");
let seen = provider.seen.lock().unwrap().clone().expect("request seen");
assert_eq!(seen.model, "test-model");
assert!(seen.system.is_some_and(|s| s.contains("PRIOR_SUMMARY")));
assert_eq!(seen.messages.len(), 1);
assert_eq!(seen.max_tokens, Some(1024));
let user_text = match &seen.messages[0].content[0] {
LlmContent::Text(t) => t.clone(),
_ => panic!("expected text content"),
};
assert!(user_text.contains("PRIOR_SUMMARY:"));
assert!(user_text.contains("prior anchor"));
assert!(user_text.contains("TRANSCRIPT:"));
assert!(user_text.contains("user: hi"));
assert!(user_text.contains("assistant: hello"));
}
#[tokio::test]
async fn first_compaction_uses_none_marker() {
let provider = Arc::new(CannedProvider::new("first summary"));
let summ = LlmSummarizer::new(provider.clone(), "test-model", 256);
let out = summ
.summarize("", &[LlmMessage::user("a"), LlmMessage::assistant("b")])
.await;
assert_eq!(out, "first summary");
let seen = provider.seen.lock().unwrap().clone().expect("request seen");
let user_text = match &seen.messages[0].content[0] {
LlmContent::Text(t) => t.clone(),
_ => panic!("expected text content"),
};
assert!(user_text.contains("(none — first compaction)"));
}
#[tokio::test]
async fn provider_error_returns_prior_summary_unchanged() {
let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
let prior = "this is the anchor";
let out = summ.summarize(prior, &[LlmMessage::user("hi")]).await;
assert_eq!(
out, prior,
"fail-soft: prior summary survives provider errors"
);
}
#[tokio::test]
async fn empty_inputs_short_circuit() {
let summ = LlmSummarizer::new(Arc::new(ErroringProvider), "test-model", 256);
let out = summ.summarize("", &[]).await;
assert!(out.is_empty());
}
#[tokio::test]
async fn empty_output_falls_back_to_prior() {
struct EmptyProvider;
#[async_trait]
impl LlmProvider for EmptyProvider {
type Error = DummyError;
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
Ok(stream::iter(vec![Ok(Chunk::Stop(StopReason::EndTurn))]).boxed())
}
}
let summ = LlmSummarizer::new(Arc::new(EmptyProvider), "test-model", 256);
let out = summ.summarize("keep me", &[LlmMessage::user("x")]).await;
assert_eq!(out, "keep me");
}
#[test]
fn tool_calls_render_as_clipped_prose_not_bracket_dsl() {
let long_args = format!("{{\"q\":\"{}\"}}", "x".repeat(TOOL_ARGS_CLIP_BYTES));
let use_msg = LlmMessage {
role: Role::Assistant,
content: vec![LlmContent::tool_use("call-1", "search", long_args)],
};
let use_rendered = render_transcript(&[use_msg]);
assert!(!use_rendered.contains("[tool_call"));
assert!(use_rendered.contains("called tool search("));
assert!(use_rendered.contains("bytes omitted"));
let long_result = format!("{{\"id\":\"{}\"}}", "y".repeat(TOOL_RESULT_CLIP_BYTES));
let result_msg = LlmMessage {
role: Role::Tool,
content: vec![LlmContent::tool_result("call-1", long_result, false, true)],
};
let result_rendered = render_transcript(&[result_msg]);
assert!(result_rendered.contains("tool result for call-1 →"));
assert!(!result_rendered.contains("[tool_result"));
assert!(result_rendered.contains("bytes omitted"));
let short = render_transcript(&[LlmMessage {
role: Role::Tool,
content: vec![LlmContent::tool_result(
"call-2",
"{\"ok\":true}",
false,
true,
)],
}]);
assert!(short.contains("tool result for call-2 → {\"ok\":true}"));
assert!(!short.contains("bytes omitted"));
}
#[test]
fn clip_truncates_on_char_boundary_without_panicking() {
let s = "é".repeat(100); let out = clip(&s, 5); assert!(out.contains("bytes omitted"));
assert!(out.starts_with("éé")); }
}