rho-coding-agent 0.8.0

A lightweight agent harness inspired by Pi
use crate::model::{ContentBlock, Message};
use crate::tool::ToolResult;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompactionConfig {
    pub auto_compact: bool,
    pub threshold_percent: u8,
    pub recent_messages: usize,
}

impl Default for CompactionConfig {
    fn default() -> Self {
        Self {
            auto_compact: false,
            threshold_percent: 85,
            recent_messages: 8,
        }
    }
}

#[derive(Clone, Debug)]
pub struct CompactionPartition {
    pub leading_messages: Vec<Message>,
    pub compacted_messages: Vec<Message>,
    pub recent_messages: Vec<Message>,
}

pub fn should_compact(
    config: &CompactionConfig,
    estimated_tokens: Option<u64>,
    context_window: Option<u64>,
) -> bool {
    if !config.auto_compact {
        return false;
    }
    let Some(tokens) = estimated_tokens else {
        return false;
    };
    let Some(window) = context_window.filter(|window| *window > 0) else {
        return false;
    };
    let threshold = u64::from(config.threshold_percent.clamp(1, 100));
    tokens.saturating_mul(100) >= window.saturating_mul(threshold)
}

pub fn partition_messages_for_compaction(
    messages: &[Message],
    recent_messages: usize,
) -> Option<CompactionPartition> {
    let first_compactable = messages
        .iter()
        .position(|message| !matches!(message, Message::System(_)))
        .unwrap_or(messages.len());
    let body_len = messages.len().saturating_sub(first_compactable);
    if body_len <= recent_messages {
        return None;
    }

    let desired_recent_start = messages.len().saturating_sub(recent_messages);
    let recent_start = tool_group_start_containing(messages, desired_recent_start)
        .unwrap_or(desired_recent_start)
        .max(first_compactable);
    if recent_start <= first_compactable {
        return None;
    }

    Some(CompactionPartition {
        leading_messages: messages[..first_compactable].to_vec(),
        compacted_messages: messages[first_compactable..recent_start].to_vec(),
        recent_messages: messages[recent_start..].to_vec(),
    })
}

pub fn build_summary_request_messages(compacted_messages: &[Message]) -> Vec<Message> {
    vec![
        Message::System(
            "Summarize the conversation history for continuation. Preserve user goals, decisions, constraints, files changed, tool outcomes, and unresolved tasks. Be concise and factual."
                .into(),
        ),
        Message::user_text(render_messages_for_summary(compacted_messages)),
    ]
}

pub fn replacement_history_from_summary(
    partition: CompactionPartition,
    summary: String,
) -> Vec<Message> {
    let mut replacement = partition.leading_messages;
    replacement.push(Message::user_text(format!(
        "Summary of earlier conversation:\n\n{}",
        summary.trim()
    )));
    replacement.extend(partition.recent_messages);
    replacement
}

fn tool_group_start_containing(messages: &[Message], index: usize) -> Option<usize> {
    for (candidate, message) in messages.iter().enumerate() {
        let Message::Assistant(blocks) = message else {
            continue;
        };
        let tool_call_count = blocks
            .iter()
            .filter(|block| matches!(block, ContentBlock::ToolCall(_)))
            .count();
        if tool_call_count == 0 {
            continue;
        }
        let group_end = candidate.saturating_add(1).saturating_add(tool_call_count);
        if candidate < index && index < group_end {
            return Some(candidate);
        }
    }
    None
}

fn render_messages_for_summary(messages: &[Message]) -> String {
    messages
        .iter()
        .map(render_message_for_summary)
        .collect::<Vec<_>>()
        .join("\n\n")
}

fn render_message_for_summary(message: &Message) -> String {
    match message {
        Message::System(text) => format!("system:\n{text}"),
        Message::User(blocks) => format!("user:\n{}", render_blocks(blocks)),
        Message::Assistant(blocks) => format!("assistant:\n{}", render_blocks(blocks)),
        Message::ToolResult(result) => format!("tool result:\n{}", render_tool_result(result)),
    }
}

fn render_blocks(blocks: &[ContentBlock]) -> String {
    blocks
        .iter()
        .map(|block| match block {
            ContentBlock::Text(text) => text.clone(),
            ContentBlock::ToolCall(call) => serde_json::to_string(call).unwrap_or_default(),
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn render_tool_result(result: &ToolResult) -> String {
    serde_json::to_string(result).unwrap_or_else(|_| result.content.clone())
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;
    use crate::tool::ToolCall;

    #[test]
    fn compaction_decision_requires_enabled_config_and_window() {
        let config = CompactionConfig {
            auto_compact: true,
            threshold_percent: 80,
            recent_messages: 4,
        };

        assert!(should_compact(&config, Some(800), Some(1_000)));
        assert!(!should_compact(&config, Some(799), Some(1_000)));
        assert!(!should_compact(&config, Some(900), None));
        assert!(!should_compact(
            &CompactionConfig {
                auto_compact: false,
                ..config
            },
            Some(900),
            Some(1_000)
        ));
    }

    #[test]
    fn partitions_messages_around_recent_tail() {
        let messages = vec![
            Message::System("system".into()),
            Message::user_text("old user"),
            Message::assistant_text("old assistant"),
            Message::user_text("recent user"),
            Message::assistant_text("recent assistant"),
        ];

        let partition = partition_messages_for_compaction(&messages, 2).unwrap();

        assert_eq!(partition.leading_messages.len(), 1);
        assert!(matches!(
            partition.compacted_messages.as_slice(),
            [Message::User(_), Message::Assistant(_)]
        ));
        assert!(matches!(
            partition.recent_messages.as_slice(),
            [Message::User(_), Message::Assistant(_)]
        ));
    }

    #[test]
    fn partition_does_not_split_assistant_tool_call_group() {
        let messages = vec![
            Message::System("system".into()),
            Message::user_text("old"),
            Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
                id: "call_1".into(),
                name: "bash".into(),
                arguments: json!({"command": "echo hi"}),
            })]),
            Message::ToolResult(ToolResult {
                id: "call_1".into(),
                ok: true,
                content: "hi".into(),
            }),
            Message::user_text("new"),
        ];

        let partition = partition_messages_for_compaction(&messages, 2).unwrap();

        assert!(matches!(
            partition.compacted_messages.as_slice(),
            [Message::User(_)]
        ));
        assert!(matches!(
            partition.recent_messages.as_slice(),
            [
                Message::Assistant(_),
                Message::ToolResult(_),
                Message::User(_)
            ]
        ));
    }

    #[test]
    fn builds_replacement_history_with_summary_between_system_and_recent_messages() {
        let partition = CompactionPartition {
            leading_messages: vec![Message::System("system".into())],
            compacted_messages: vec![Message::user_text("old")],
            recent_messages: vec![Message::user_text("new")],
        };

        let replacement = replacement_history_from_summary(partition, "remembered".into());

        assert_eq!(replacement.len(), 3);
        assert!(matches!(replacement[0], Message::System(_)));
        assert!(
            matches!(&replacement[1], Message::User(blocks) if matches!(blocks.as_slice(), [ContentBlock::Text(text)] if text.contains("remembered")))
        );
        assert!(matches!(replacement[2], Message::User(_)));
    }
}