rho-coding-agent 0.22.0

A lightweight agent harness inspired by Pi
Documentation
use crate::model::{
    context::{estimate_context_tokens, estimate_message_tokens},
    ContentBlock, Message,
};
use crate::tool::{ToolResult, ToolSpec};

const SUMMARY_RESERVE_MIN_TOKENS: u64 = 512;
const SUMMARY_RESERVE_MAX_TOKENS: u64 = 8_192;

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

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

impl CompactionConfig {
    pub fn target_tokens(&self, context_window: u64) -> u64 {
        percent_tokens(
            context_window,
            normalized_target_percent(self.threshold_percent, self.target_percent),
        )
    }
}

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

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct MessageGroup {
    start: usize,
    end: usize,
    tokens: u64,
}

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;
    };
    tokens >= percent_tokens(window, normalized_percent(config.threshold_percent))
}

pub fn partition_messages_for_compaction(
    messages: &[Message],
    tools: &[ToolSpec],
    target_tokens: u64,
) -> Option<CompactionPartition> {
    let first_compactable = messages
        .iter()
        .position(|message| !matches!(message, Message::System(_)))
        .unwrap_or(messages.len());
    let groups = message_groups(messages, first_compactable);
    if groups.len() <= 1 {
        return None;
    }

    let recent_token_budget =
        recent_tail_token_budget(&messages[..first_compactable], tools, target_tokens);
    let recent_start = recent_tail_start(&groups, recent_token_budget)?;
    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 compacted conversation history for continuation. The original transcript is still stored separately; this summary replaces only older model context. Preserve user goals, constraints, decisions, files changed, tool calls and results, test results, unresolved tasks, and continuation-critical details such as paths, commands, errors, IDs, and pending next steps. 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!(
        "Automatic compaction summary of earlier conversation for model context only:\n\n{}",
        summary.trim()
    )));
    replacement.extend(partition.recent_messages);
    replacement
}

fn recent_tail_token_budget(
    leading_messages: &[Message],
    tools: &[ToolSpec],
    target_tokens: u64,
) -> u64 {
    let fixed_tokens = estimate_context_tokens(leading_messages, tools);
    target_tokens
        .saturating_sub(fixed_tokens)
        .saturating_sub(summary_reserve_tokens(target_tokens))
}

fn recent_tail_start(groups: &[MessageGroup], token_budget: u64) -> Option<usize> {
    let mut tail_start = None;
    let mut tail_tokens = 0_u64;
    for group in groups.iter().rev() {
        let next_tokens = tail_tokens.saturating_add(group.tokens);
        if tail_start.is_some() && next_tokens > token_budget {
            break;
        }
        tail_tokens = next_tokens;
        tail_start = Some(group.start);
    }
    tail_start
}

fn message_groups(messages: &[Message], start: usize) -> Vec<MessageGroup> {
    let mut groups = Vec::new();
    let mut index = start;
    while index < messages.len() {
        let end = completed_tool_group_end(messages, index).unwrap_or(index + 1);
        let tokens = messages[index..end]
            .iter()
            .map(estimate_message_tokens)
            .sum();
        groups.push(MessageGroup {
            start: index,
            end,
            tokens,
        });
        index = end;
    }
    groups
}

fn completed_tool_group_end(messages: &[Message], index: usize) -> Option<usize> {
    let Message::Assistant(blocks) = &messages[index] else {
        return None;
    };
    let tool_call_ids = blocks
        .iter()
        .filter_map(|block| match block {
            ContentBlock::ToolCall(call) => Some(call.id.as_str()),
            ContentBlock::Text(_) | ContentBlock::Image(_) => None,
        })
        .collect::<Vec<_>>();
    if tool_call_ids.is_empty() {
        return None;
    }

    let results_start = index + 1;
    let results_end = results_start + tool_call_ids.len();
    if results_end > messages.len() {
        return Some(messages.len());
    }
    let complete = tool_call_ids.iter().enumerate().all(|(offset, id)| {
        matches!(
            &messages[results_start + offset],
            Message::ToolResult(result) if result.id == *id
        )
    });
    Some(if complete {
        results_end
    } else {
        messages.len()
    })
}

fn summary_reserve_tokens(target_tokens: u64) -> u64 {
    if target_tokens == 0 {
        return 0;
    }
    (target_tokens / 10)
        .clamp(SUMMARY_RESERVE_MIN_TOKENS, SUMMARY_RESERVE_MAX_TOKENS)
        .min(target_tokens)
}

fn percent_tokens(total: u64, percent: u8) -> u64 {
    total.saturating_mul(u64::from(percent)).div_ceil(100)
}

fn normalized_percent(percent: u8) -> u8 {
    percent.clamp(1, 100)
}

fn normalized_target_percent(threshold_percent: u8, target_percent: u8) -> u8 {
    let threshold_percent = normalized_percent(threshold_percent);
    let target_percent = normalized_percent(target_percent);
    if threshold_percent == 1 {
        1
    } else {
        target_percent.min(threshold_percent - 1)
    }
}

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::Image(image) => format!("[image: {}]", image.mime_type),
            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,
            target_percent: 50,
        };

        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 target_percent_stays_below_threshold_when_possible() {
        let config = CompactionConfig {
            auto_compact: true,
            threshold_percent: 85,
            target_percent: 99,
        };

        assert_eq!(config.target_tokens(1_000), 840);
    }

    #[test]
    fn partitions_messages_around_token_budgeted_recent_tail() {
        let messages = vec![
            Message::System("system".into()),
            Message::user_text("x".repeat(1_000)),
            Message::assistant_text("y".repeat(1_000)),
            Message::user_text("recent user"),
            Message::assistant_text("recent assistant"),
        ];

        let partition = partition_messages_for_compaction(&messages, &[], 700).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("x".repeat(1_000)),
            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, &[], 700).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 partition_keeps_last_group_even_when_it_exceeds_budget() {
        let messages = vec![
            Message::System("system".into()),
            Message::user_text("old"),
            Message::assistant_text("z".repeat(2_000)),
        ];

        let partition = partition_messages_for_compaction(&messages, &[], 1).unwrap();

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

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

        assert!(partition_messages_for_compaction(&messages, &[], 10_000).is_none());
    }

    #[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(_)));
    }
}