use std::fmt::Write as _;
use std::sync::Arc;
use futures::StreamExt as _;
use futures::stream::BoxStream;
use indexmap::IndexMap;
use oino_agent::{StreamFn, StreamRequest, now_ms};
use oino_ai::transcript::initial_system_message;
use oino_ai::{
AgentMessage, AssistantContent, AssistantMessage, AssistantMessageEvent, CacheRetention,
Context, Message, Model, StopReason, StreamOptions, ThinkingLevel, Usage, UserContent,
UserContentField, UserMessage,
};
use oino_session::convert_to_llm;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use super::error::CompactionError;
use super::prepare::{CompactionPreparation, CompactionResult};
use super::prompts::{
SUMMARIZATION_PROMPT, SUMMARIZATION_SYSTEM_PROMPT, TURN_PREFIX_SUMMARIZATION_PROMPT,
UPDATE_SUMMARIZATION_PROMPT,
};
use super::retry::{RetryPolicy, SummarizationRetryObserver, retry_assistant_call};
use super::utils::{format_file_operations, serialize_conversation};
use super::uuid::uuidv7;
pub type Headers = IndexMap<String, Option<String>>;
#[derive(Clone)]
pub struct Summarizer {
pub stream: Arc<dyn StreamFn>,
pub model: Model,
pub api_key: Option<String>,
pub headers: Option<Headers>,
pub thinking_level: Option<ThinkingLevel>,
pub session_id: Option<String>,
pub retry: Option<RetryPolicy>,
pub observer: Option<Arc<dyn SummarizationRetryObserver>>,
pub cancel: CancellationToken,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SummaryOutput {
pub summary: String,
pub usage: Usage,
}
impl Summarizer {
#[must_use]
pub fn new(stream: Arc<dyn StreamFn>, model: Model) -> Self {
Self {
stream,
model,
api_key: None,
headers: None,
thinking_level: None,
session_id: None,
retry: None,
observer: None,
cancel: CancellationToken::new(),
}
}
#[must_use]
pub fn summarization_options(&self) -> StreamOptions {
StreamOptions {
headers: self.headers.clone(),
session_id: self.session_id.clone(),
reasoning: self
.thinking_level
.filter(|level| self.model.reasoning && *level != ThinkingLevel::Off),
..StreamOptions::default()
}
}
pub async fn complete(&self, context: Context, mut options: StreamOptions) -> AssistantMessage {
options.cache_retention = Some(CacheRetention::None);
options.session_id = Some(options.session_id.unwrap_or_else(uuidv7));
let produce = || {
let events = self.stream.stream(StreamRequest {
model: self.model.clone(),
context: context.clone(),
options: options.clone(),
api_key: self.api_key.clone(),
cancel: self.cancel.clone(),
});
final_message(events, &self.model, &self.cancel)
};
retry_assistant_call(
produce,
self.retry.as_ref(),
&self.cancel,
self.observer.as_deref(),
)
.await
}
}
async fn final_message(
mut events: BoxStream<'static, AssistantMessageEvent>,
model: &Model,
cancel: &CancellationToken,
) -> AssistantMessage {
let mut last_partial = None;
while let Some(event) = events.next().await {
match event {
AssistantMessageEvent::Done { message, .. }
| AssistantMessageEvent::Error { error: message, .. } => return message,
AssistantMessageEvent::Start { partial } => last_partial = Some(partial),
_ => {}
}
}
let mut message = last_partial.unwrap_or_else(|| {
AssistantMessage::empty(&model.api, &model.provider, &model.id, now_ms())
});
message.stop_reason = if cancel.is_cancelled() {
StopReason::Aborted
} else {
StopReason::Error
};
message
.error_message
.get_or_insert_with(|| "Stream ended without a final event".to_owned());
message
}
#[must_use]
pub fn summarization_context(prompt_text: &str) -> Context {
let system = initial_system_message(SUMMARIZATION_SYSTEM_PROMPT, &[]).map(Message::System);
let user = Message::User(UserMessage {
content: UserContentField::Blocks(vec![UserContent::text(prompt_text)]),
timestamp: now_ms(),
});
Context {
messages: system.into_iter().chain(std::iter::once(user)).collect(),
}
}
#[must_use]
pub fn summarization_failure(response: &AssistantMessage, label: &str) -> Option<String> {
match response.stop_reason {
StopReason::Error => Some(format!(
"{label} failed: {}",
response
.error_message
.as_deref()
.filter(|text| !text.is_empty())
.unwrap_or("Unknown error")
)),
StopReason::Length => Some(format!(
"{label} failed: generation hit the token cap and the summary is incomplete"
)),
_ => None,
}
}
pub(crate) fn response_text(response: &AssistantMessage) -> String {
response
.content
.iter()
.filter_map(|block| match block {
AssistantContent::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn has_tool_call(response: &AssistantMessage) -> bool {
response
.content
.iter()
.any(|block| matches!(block, AssistantContent::ToolCall(_)))
}
fn non_empty(text: Option<&str>) -> Option<&str> {
text.filter(|text| !text.is_empty())
}
async fn summarize_prompt(
summarizer: &Summarizer,
prompt_text: &str,
label: &'static str,
) -> Result<SummaryOutput, CompactionError> {
let response = summarizer
.complete(
summarization_context(prompt_text),
summarizer.summarization_options(),
)
.await;
if let Some(failure) = summarization_failure(&response, label) {
return Err(CompactionError::Summarization(failure));
}
if has_tool_call(&response) {
return Err(CompactionError::ToolCall(label));
}
Ok(SummaryOutput {
summary: response_text(&response),
usage: response.usage,
})
}
pub async fn generate_summary(
messages: &[AgentMessage],
summarizer: &Summarizer,
custom_instructions: Option<&str>,
previous_summary: Option<&str>,
) -> Result<SummaryOutput, CompactionError> {
let previous_summary = non_empty(previous_summary);
let mut base_prompt = if previous_summary.is_some() {
UPDATE_SUMMARIZATION_PROMPT.to_owned()
} else {
SUMMARIZATION_PROMPT.to_owned()
};
if let Some(instructions) = non_empty(custom_instructions) {
base_prompt = format!("{base_prompt}\n\nAdditional focus: {instructions}");
}
let conversation = serialize_conversation(&convert_to_llm(messages));
let mut prompt = format!("<conversation>\n{conversation}\n</conversation>\n\n");
if let Some(previous) = previous_summary {
let _ = write!(
prompt,
"<previous-summary>\n{previous}\n</previous-summary>\n\n"
);
}
prompt.push_str(&base_prompt);
summarize_prompt(summarizer, &prompt, "Summarization").await
}
async fn generate_turn_prefix_summary(
messages: &[AgentMessage],
summarizer: &Summarizer,
custom_instructions: Option<&str>,
) -> Result<SummaryOutput, CompactionError> {
let conversation = serialize_conversation(&convert_to_llm(messages));
let mut prompt = format!(
"# Conversation\n{conversation}\n\n# Instructions\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
);
if let Some(instructions) = non_empty(custom_instructions) {
prompt = format!("{prompt}\n\nAdditional focus: {instructions}");
}
summarize_prompt(summarizer, &prompt, "Turn prefix summarization").await
}
pub async fn summarize_default(
messages: &[AgentMessage],
custom_instructions: Option<&str>,
previous_summary: Option<&str>,
summarizer: &Summarizer,
) -> Result<SummaryOutput, CompactionError> {
generate_summary(messages, summarizer, custom_instructions, previous_summary).await
}
pub async fn compact(
preparation: &CompactionPreparation,
summarizer: &Summarizer,
custom_instructions: Option<&str>,
) -> Result<CompactionResult, CompactionError> {
let previous = preparation.previous_summary.as_deref();
let (mut summary, usage) =
if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
let history = if preparation.messages_to_summarize.is_empty() {
None
} else {
Some(
generate_summary(
&preparation.messages_to_summarize,
summarizer,
custom_instructions,
previous,
)
.await?,
)
};
let prefix = generate_turn_prefix_summary(
&preparation.turn_prefix_messages,
summarizer,
custom_instructions,
)
.await?;
let (history_text, usage) = match history {
Some(history) => {
let mut usage = history.usage;
usage.accumulate(&prefix.usage);
(history.summary, usage)
}
None => (
previous.unwrap_or("No prior history.").to_owned(),
prefix.usage,
),
};
(
format!(
"{history_text}\n\n---\n\n**Turn Context (split turn):**\n\n{}",
prefix.summary
),
usage,
)
} else {
let output = generate_summary(
&preparation.messages_to_summarize,
summarizer,
custom_instructions,
previous,
)
.await?;
(output.summary, output.usage)
};
let lists = preparation.file_ops.file_lists();
summary.push_str(&format_file_operations(
&lists.read_files,
&lists.modified_files,
));
if preparation.first_kept_entry_id.as_str().is_empty() {
return Err(CompactionError::MissingFirstKept);
}
Ok(CompactionResult {
summary,
first_kept_entry_id: preparation.first_kept_entry_id.clone(),
tokens_before: preparation.tokens_before,
estimated_tokens_after: None,
usage: Some(usage),
details: serde_json::to_value(&lists).ok(),
})
}