vv-agent 0.11.0

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::collections::BTreeMap;

use crate::memory::artifacts::{
    build_compacted_tool_content, has_recovery_envelope, is_compacted_tool_content,
    ToolResultArtifactConfig,
};
use crate::memory::token_utils::count_messages_tokens;
use crate::tools::ToolResultRetention;
use crate::types::{Message, MessageRole, ToolArtifactRef};
use crate::workspace::{persist_text_artifact, read_validated_text_artifact};

use super::MemoryManager;

#[derive(Debug, Clone)]
pub(crate) struct MicrocompactionPlan {
    candidates: Vec<MicrocompactionCandidate>,
    current_usage: u64,
    target_usage: u64,
    pub(crate) candidate_count: usize,
    pub(crate) estimated_reclaimable_tokens: u64,
}

#[derive(Debug, Clone)]
struct MicrocompactionCandidate {
    message_index: usize,
    tool_name: String,
    existing_artifact: Option<ToolArtifactRef>,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct MicrocompactionApplication {
    pub(crate) messages: Vec<Message>,
    pub(crate) archived_count: usize,
    pub(crate) reclaimed_tokens: u64,
    pub(crate) artifact_failure_count: usize,
}

impl MicrocompactionPlan {
    pub(crate) fn empty() -> Self {
        Self {
            candidates: Vec::new(),
            current_usage: 0,
            target_usage: 0,
            candidate_count: 0,
            estimated_reclaimable_tokens: 0,
        }
    }

    pub(crate) fn has_candidates(&self) -> bool {
        !self.candidates.is_empty()
    }
}

impl MemoryManager {
    pub(crate) fn validate_model_recovery_surface(
        &mut self,
        messages: &[Message],
        tool_schemas: &[serde_json::Value],
    ) -> Result<(), String> {
        let recovery_tool_available = tool_schemas.iter().any(|schema| {
            schema["function"]["name"].as_str() == Some(crate::constants::READ_FILE_TOOL_NAME)
        });
        self.recovery_tool_available = recovery_tool_available;
        if !recovery_tool_available
            && messages
                .iter()
                .any(|message| is_compacted_tool_content(&message.content))
        {
            return Err(
                "microcompaction_recovery_unavailable: compacted tool results require \
                 a model-visible read_file tool"
                    .to_string(),
            );
        }
        Ok(())
    }

    pub(crate) fn plan_cycle_microcompaction(
        &self,
        messages: &[Message],
        current_cycle: u32,
        current_usage: u64,
    ) -> MicrocompactionPlan {
        let cleaned = self.remove_previous_summary(messages);
        let sanitized = crate::memory::message_sanitizer::filter_empty_assistant_messages(&cleaned);
        self.plan_microcompaction(&sanitized, current_cycle, current_usage)
    }

    pub(crate) fn plan_microcompaction(
        &self,
        messages: &[Message],
        current_cycle: u32,
        current_usage: u64,
    ) -> MicrocompactionPlan {
        if !self.recovery_tool_available
            || messages.is_empty()
            || current_usage <= self.microcompact_trigger_threshold()
        {
            return MicrocompactionPlan::empty();
        }

        let policy = self.config.microcompaction_policy;
        let tool_call_names = build_tool_call_name_map(messages);
        let inferred_cycles = infer_message_cycles(messages);
        let max_inferred_cycle = inferred_cycles.last().copied().unwrap_or_default();
        let effective_current_cycle = current_cycle.max(max_inferred_cycle.saturating_add(1));
        let protected_cycle = effective_current_cycle.saturating_sub(policy.keep_recent_cycles);
        let target = self.microcompact_target_threshold();
        let marker_config = self.marker_config();
        let mut candidates = Vec::new();
        let mut estimated_reclaimable_tokens = 0u64;

        for (message_index, (message, inferred_cycle)) in
            messages.iter().zip(inferred_cycles).enumerate()
        {
            let Some(tool_name) = eligible_tool_name(
                message,
                inferred_cycle,
                protected_cycle,
                policy.min_result_chars as usize,
                &tool_call_names,
                &self.tool_result_retentions,
            ) else {
                continue;
            };
            let artifact_path = message
                .artifact_ref
                .as_ref()
                .map(|artifact| artifact.path.as_str())
                .unwrap_or(".vv-agent/artifacts/task/call-00000000000000000000000000000000.txt");
            let excerpt_source = message
                .metadata
                .get("_vv_agent_microcompact_excerpt")
                .and_then(serde_json::Value::as_str)
                .unwrap_or(&message.content);
            let estimated_marker = build_compacted_tool_content(
                excerpt_source,
                artifact_path,
                tool_name,
                &marker_config,
            );
            let before = count_messages_tokens(std::slice::from_ref(message), &self.config.model);
            let after = count_messages_tokens(
                &[Message::tool(
                    estimated_marker,
                    message.tool_call_id.clone().unwrap_or_default(),
                )],
                &self.config.model,
            );
            let reclaimable = before.saturating_sub(after);
            if reclaimable == 0 {
                continue;
            }
            estimated_reclaimable_tokens = estimated_reclaimable_tokens.saturating_add(reclaimable);
            candidates.push(MicrocompactionCandidate {
                message_index,
                tool_name: tool_name.to_string(),
                existing_artifact: message.artifact_ref.clone(),
            });
        }

        MicrocompactionPlan {
            candidate_count: candidates.len(),
            candidates,
            current_usage,
            target_usage: target,
            estimated_reclaimable_tokens,
        }
    }

    pub(crate) fn apply_microcompaction(
        &self,
        messages: &[Message],
        plan: &MicrocompactionPlan,
    ) -> MicrocompactionApplication {
        if !plan.has_candidates() {
            return MicrocompactionApplication {
                messages: messages.to_vec(),
                ..MicrocompactionApplication::default()
            };
        }
        let Some(backend) = self.workspace_backend.as_ref() else {
            return MicrocompactionApplication {
                messages: messages.to_vec(),
                artifact_failure_count: plan.candidate_count,
                ..MicrocompactionApplication::default()
            };
        };

        let marker_config = self.marker_config();
        let mut updated = messages.to_vec();
        let mut archived_count = 0usize;
        let mut reclaimed_tokens = 0u64;
        let mut artifact_failure_count = 0usize;
        let mut projected_usage = plan.current_usage;
        for candidate in &plan.candidates {
            if projected_usage <= plan.target_usage {
                break;
            }
            let message = &messages[candidate.message_index];
            let Some((artifact, complete_content)) =
                archive_tool_message(self, backend, message, candidate)
            else {
                artifact_failure_count += 1;
                continue;
            };
            let marker = build_compacted_tool_content(
                &complete_content,
                &artifact.path,
                &candidate.tool_name,
                &marker_config,
            );
            let before = count_messages_tokens(std::slice::from_ref(message), &self.config.model);
            let mut replacement = message.clone();
            replacement.content = marker;
            replacement.artifact_ref = Some(artifact);
            replacement
                .metadata
                .remove("_vv_agent_microcompact_excerpt");
            let after =
                count_messages_tokens(std::slice::from_ref(&replacement), &self.config.model);
            let actual_reclaimed = before.saturating_sub(after);
            if actual_reclaimed == 0 {
                continue;
            }
            reclaimed_tokens = reclaimed_tokens.saturating_add(actual_reclaimed);
            updated[candidate.message_index] = replacement;
            archived_count += 1;
            projected_usage = projected_usage.saturating_sub(actual_reclaimed);
        }
        MicrocompactionApplication {
            messages: updated,
            archived_count,
            reclaimed_tokens,
            artifact_failure_count,
        }
    }

    pub fn microcompact_trigger_threshold(&self) -> u64 {
        (self.autocompact_threshold() as f64 * self.config.microcompaction_policy.trigger_ratio)
            .floor() as u64
    }

    pub fn microcompact_target_threshold(&self) -> u64 {
        (self.autocompact_threshold() as f64 * self.config.microcompaction_policy.target_ratio)
            .floor() as u64
    }

    pub fn should_preemptive_microcompact(&self, message_length: u64) -> bool {
        let threshold = self.microcompact_trigger_threshold();
        threshold > 0 && message_length > threshold
    }

    pub fn microcompact_messages(
        &self,
        messages: &[Message],
        cycle_index: Option<u32>,
    ) -> (Vec<Message>, usize) {
        let Some(cycle_index) = cycle_index else {
            return (messages.to_vec(), 0);
        };
        let current_usage = count_messages_tokens(messages, &self.config.model);
        let plan = self.plan_microcompaction(messages, cycle_index, current_usage);
        let application = self.apply_microcompaction(messages, &plan);
        (application.messages, application.archived_count)
    }

    fn marker_config(&self) -> ToolResultArtifactConfig {
        ToolResultArtifactConfig {
            artifact_namespace: self.artifact_namespace.clone(),
            compact_threshold: self.config.tool_result_compact_threshold,
            keep_last: self.config.tool_result_keep_last,
            excerpt_head: self.config.tool_result_excerpt_head,
            excerpt_tail: self.config.tool_result_excerpt_tail,
        }
    }
}

fn archive_tool_message(
    manager: &MemoryManager,
    backend: &std::sync::Arc<dyn crate::workspace::WorkspaceBackend>,
    message: &Message,
    candidate: &MicrocompactionCandidate,
) -> Option<(ToolArtifactRef, String)> {
    if let Some(artifact) = candidate.existing_artifact.as_ref() {
        let complete_content = read_validated_text_artifact(backend.as_ref(), artifact).ok()?;
        return Some((artifact.clone(), complete_content));
    }
    if has_recovery_envelope(&message.content) {
        return None;
    }
    let artifact = persist_text_artifact(
        backend.clone(),
        &manager.artifact_namespace,
        message.tool_call_id.as_deref().unwrap_or("tool-result"),
        &message.content,
    )
    .ok()?;
    Some((artifact, message.content.clone()))
}

fn build_tool_call_name_map(messages: &[Message]) -> BTreeMap<String, String> {
    let mut tool_call_names = BTreeMap::new();
    for message in messages {
        if message.role != MessageRole::Assistant {
            continue;
        }
        for tool_call in &message.tool_calls {
            tool_call_names.insert(tool_call.id.clone(), tool_call.name.clone());
        }
    }
    tool_call_names
}

fn infer_message_cycles(messages: &[Message]) -> Vec<u32> {
    let mut current_cycle = 0;
    let mut inferred = Vec::with_capacity(messages.len());
    for message in messages {
        if message.role == MessageRole::Assistant {
            current_cycle += 1;
        }
        inferred.push(current_cycle);
    }
    inferred
}

fn eligible_tool_name<'a>(
    message: &'a Message,
    inferred_cycle: u32,
    protected_cycle: u32,
    min_result_chars: usize,
    tool_call_names: &'a BTreeMap<String, String>,
    retentions: &BTreeMap<String, ToolResultRetention>,
) -> Option<&'a str> {
    if message.role != MessageRole::Tool
        || inferred_cycle >= protected_cycle
        || message.content.chars().count() <= min_result_chars
        || is_compacted_tool_content(&message.content)
        || (message.artifact_ref.is_none() && has_recovery_envelope(&message.content))
    {
        return None;
    }
    let tool_name = tool_call_names.get(message.tool_call_id.as_deref()?)?;
    (retentions.get(tool_name).copied().unwrap_or_default() == ToolResultRetention::Archive)
        .then_some(tool_name.as_str())
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use crate::memory::{MemoryManager, MemoryManagerConfig};
    use crate::types::Message;
    use crate::workspace::MemoryWorkspaceBackend;

    use super::{MicrocompactionCandidate, MicrocompactionPlan};

    #[test]
    fn application_skips_zero_gain_replacement_and_continues_to_later_candidate() {
        let manager = MemoryManager::new(MemoryManagerConfig::default())
            .with_workspace_backend(Arc::new(MemoryWorkspaceBackend::default()));
        let messages = vec![
            Message::tool("small result", "first"),
            Message::tool("large result ".repeat(1_000), "second"),
        ];
        let plan = MicrocompactionPlan {
            candidates: vec![
                MicrocompactionCandidate {
                    message_index: 0,
                    tool_name: "first_tool".to_string(),
                    existing_artifact: None,
                },
                MicrocompactionCandidate {
                    message_index: 1,
                    tool_name: "second_tool".to_string(),
                    existing_artifact: None,
                },
            ],
            current_usage: 100,
            target_usage: 99,
            candidate_count: 2,
            estimated_reclaimable_tokens: 200,
        };

        let applied = manager.apply_microcompaction(&messages, &plan);

        assert_eq!(applied.archived_count, 1);
        assert_eq!(applied.artifact_failure_count, 0);
        assert_eq!(applied.messages[0], messages[0]);
        assert!(applied.messages[1]
            .content
            .starts_with(crate::memory::TOOL_RESULT_COMPACT_MARKER));
    }
}