use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use brainwires_core::{ChatOptions, Message, Provider};
#[async_trait]
pub trait Summarizer: Send + Sync {
async fn summarize(&self, messages: &[Message]) -> Result<String>;
}
pub struct LlmSummarizer {
provider: Arc<dyn Provider>,
options: ChatOptions,
system_prompt: String,
}
impl LlmSummarizer {
pub const DEFAULT_SYSTEM_PROMPT: &'static str = concat!(
"You compress conversation history for an AI agent. ",
"Produce a single compact summary (5-15 sentences) that preserves: ",
"tool-call outcomes and errors, decisions the assistant made, ",
"commitments or constraints the user expressed, and any unresolved ",
"questions. Discard: raw tool arguments, intermediate reasoning, ",
"verbatim source code, and pleasantries. Write in past tense as a ",
"neutral observer ('The assistant read X and concluded Y. The user ",
"asked for Z.'). Output plain text only — no headings or markdown."
);
pub fn new(provider: Arc<dyn Provider>) -> Self {
Self {
provider,
options: ChatOptions::default().temperature(0.0).max_tokens(1024),
system_prompt: Self::DEFAULT_SYSTEM_PROMPT.to_string(),
}
}
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = prompt.into();
self
}
pub fn with_options(mut self, options: ChatOptions) -> Self {
self.options = options;
self
}
}
#[async_trait]
impl Summarizer for LlmSummarizer {
async fn summarize(&self, messages: &[Message]) -> Result<String> {
if messages.is_empty() {
return Ok(String::new());
}
let transcript = render_transcript(messages);
let mut opts = self.options.clone();
opts.system = Some(self.system_prompt.clone());
let payload = format!(
"Summarize the following conversation per the system-prompt rules. \
Return the summary only — no preamble.\n\n---\n{transcript}\n---"
);
let resp = self
.provider
.chat(&[Message::user(payload)], None, &opts)
.await?;
Ok(resp.message.text().unwrap_or_default().trim().to_string())
}
}
fn render_transcript(messages: &[Message]) -> String {
use brainwires_core::{ContentBlock, MessageContent, Role};
let mut out = String::new();
for m in messages {
let role = match m.role {
Role::System => "SYSTEM",
Role::User => "USER",
Role::Assistant => "ASSISTANT",
Role::Tool => "TOOL",
};
match &m.content {
MessageContent::Text(t) => {
out.push_str(&format!("{role}: {t}\n"));
}
MessageContent::Blocks(blocks) => {
for b in blocks {
match b {
ContentBlock::Text { text } => {
out.push_str(&format!("{role}: {text}\n"));
}
ContentBlock::ToolUse { name, .. } => {
out.push_str(&format!("{role}: (called tool `{name}`)\n"));
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
let status = if is_error.unwrap_or(false) {
"err"
} else {
"ok"
};
let snippet: String = content.chars().take(200).collect();
out.push_str(&format!("{role}: (tool {status}) {snippet}\n"));
}
ContentBlock::Image { .. } => {
out.push_str(&format!("{role}: (image attached)\n"));
}
}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use brainwires_core::{
ChatResponse, ContentBlock, Message, MessageContent, Provider, Role, StreamChunk, Tool,
Usage,
};
use futures::stream::BoxStream;
struct EchoingProvider;
#[async_trait]
impl Provider for EchoingProvider {
fn name(&self) -> &str {
"echo"
}
async fn chat(
&self,
messages: &[Message],
_: Option<&[Tool]>,
_: &ChatOptions,
) -> Result<ChatResponse> {
let last = messages.last().and_then(|m| m.text()).unwrap_or_default();
let first_line = last.lines().next().unwrap_or_default();
Ok(ChatResponse {
message: Message::assistant(format!("summary-of: {first_line}")),
usage: Usage::new(10, 4),
finish_reason: Some("stop".into()),
})
}
fn stream_chat<'a>(
&'a self,
_: &'a [Message],
_: Option<&'a [Tool]>,
_: &'a ChatOptions,
) -> BoxStream<'a, Result<StreamChunk>> {
Box::pin(futures::stream::empty())
}
}
#[tokio::test]
async fn summarizes_empty_history() {
let s = LlmSummarizer::new(Arc::new(EchoingProvider));
assert_eq!(s.summarize(&[]).await.unwrap(), "");
}
#[tokio::test]
async fn renders_mixed_content_into_transcript() {
let msgs = vec![
Message::system("be helpful"),
Message::user("read foo.rs"),
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::Text {
text: "I'll read it.".into(),
},
ContentBlock::ToolUse {
id: "t1".into(),
name: "read_file".into(),
input: serde_json::json!({"path":"foo.rs"}),
},
]),
name: None,
metadata: None,
},
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".into(),
content: "fn main() {}".into(),
is_error: Some(false),
}]),
name: None,
metadata: None,
},
];
let rendered = render_transcript(&msgs);
assert!(rendered.contains("SYSTEM: be helpful"));
assert!(rendered.contains("USER: read foo.rs"));
assert!(rendered.contains("ASSISTANT: I'll read it."));
assert!(rendered.contains("ASSISTANT: (called tool `read_file`)"));
assert!(rendered.contains("USER: (tool ok) fn main() {}"));
}
#[tokio::test]
async fn llm_summarizer_invokes_provider_and_returns_text() {
let s = LlmSummarizer::new(Arc::new(EchoingProvider));
let msgs = vec![Message::user("hello world"), Message::assistant("hi!")];
let summary = s.summarize(&msgs).await.unwrap();
assert!(summary.starts_with("summary-of:"));
}
}