stynx-code-compact 3.6.1

Context compaction and conversation summarization
Documentation
use stynx_code_errors::AppResult;
use stynx_code_types::{ContentBlock, Conversation, Message, Provider, Role, StreamEvent};
use futures::StreamExt;

use crate::prompt;

pub struct FullCompactor;

impl Default for FullCompactor {
    fn default() -> Self {
        Self
    }
}

impl FullCompactor {
    pub fn new() -> Self {
        Self
    }

    pub async fn compact(
        &self,
        conversation: &Conversation,
        provider: &dyn Provider,
    ) -> AppResult<Conversation> {

        let conversation_text = self.build_conversation_text(conversation);

        let conversation_text = if conversation_text.len() > 50_000 {
            format!("{}...\n(truncated)", &conversation_text[..50_000])
        } else {
            conversation_text
        };

        let mut summary_conv = Conversation {
            system: Some(prompt::compaction_system_prompt()),
            ..Default::default()
        };
        summary_conv.push(Message::user(prompt::compaction_user_prompt(
            &conversation_text,
        )));

        let tools: Vec<serde_json::Value> = vec![];
        let mut summary_text = String::new();

        match provider.stream(&summary_conv, &tools).await {
            Ok(mut stream) => {
                while let Some(event) = stream.next().await {
                    if let StreamEvent::ContentDelta { text } = event {
                        summary_text.push_str(&text);
                    }
                }
            }
            Err(e) => {
                tracing::error!("Full compaction failed: {e}");

                return Ok(self.fallback_compact(conversation));
            }
        }

        if summary_text.is_empty() {
            summary_text = "Previous conversation context was compacted.".into();
        }

        let mut compacted = Conversation {
            system: conversation.system.clone(),
            ..Default::default()
        };

        compacted.push(Message::user(format!(
            "[Context from previous conversation]\n{summary_text}"
        )));
        compacted.push(Message::assistant(vec![ContentBlock::Text {
            text: "I understand. I have the context from our previous conversation. How can I help you next?".into(),
        }]));

        let keep = conversation.messages.len().min(2);
        let start = conversation.messages.len() - keep;
        for msg in &conversation.messages[start..] {
            compacted.push(msg.clone());
        }

        Ok(compacted)
    }

    fn build_conversation_text(&self, conversation: &Conversation) -> String {
        let mut parts = Vec::new();

        for msg in &conversation.messages {
            let role = match msg.role {
                Role::User => "User",
                Role::Assistant => "Assistant",
            };
            for block in &msg.content {
                match block {
                    ContentBlock::Text { text } => {
                        parts.push(format!("{role}: {text}"));
                    }
                    ContentBlock::ToolUse { name, .. } => {
                        parts.push(format!("{role}: [used tool: {name}]"));
                    }
                    ContentBlock::ToolResult { content, .. } => {
                        let preview = if content.len() > 200 {
                            format!("{}...", &content[..200])
                        } else {
                            content.clone()
                        };
                        parts.push(format!("{role}: [tool result: {preview}]"));
                    }
                    ContentBlock::Thinking { thinking } => {
                        let preview = if thinking.len() > 200 {
                            format!("{}...", &thinking[..200])
                        } else {
                            thinking.clone()
                        };
                        parts.push(format!("{role}: [thinking: {preview}]"));
                    }
                    ContentBlock::Image { .. } => {
                        parts.push(format!("{role}: [image]"));
                    }
                }
            }
        }

        parts.join("\n")
    }

    fn fallback_compact(&self, conversation: &Conversation) -> Conversation {
        let mut compacted = Conversation {
            system: conversation.system.clone(),
            ..Default::default()
        };
        let keep = conversation.messages.len().min(4);
        let start = conversation.messages.len() - keep;
        for msg in &conversation.messages[start..] {
            compacted.push(msg.clone());
        }
        compacted
    }
}