vv-agent 0.8.0

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::cmp::Ordering;

use serde::{de::Error as _, Deserialize, Deserializer, Serialize};

use crate::checkpoint::ToolIdempotency;

pub const MAX_TOOL_METADATA_LABELS: usize = 32;
pub const MAX_TOOL_METADATA_LABEL_CODE_POINTS: usize = 128;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSideEffect {
    #[default]
    Unknown,
    None,
    Read,
    Write,
    Execute,
    Network,
    External,
}

impl ToolSideEffect {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Unknown => "unknown",
            Self::None => "none",
            Self::Read => "read",
            Self::Write => "write",
            Self::Execute => "execute",
            Self::Network => "network",
            Self::External => "external",
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ToolMetadata {
    pub side_effect: ToolSideEffect,
    pub idempotency: ToolIdempotency,
    pub terminal: bool,
    pub capability_tags: Vec<String>,
    pub cost_dimensions: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("tool_metadata_invalid: {message}")]
pub struct ToolMetadataError {
    message: String,
}

impl ToolMetadataError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }

    pub fn message(&self) -> &str {
        &self.message
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ToolMetadataWire {
    #[serde(default)]
    side_effect: ToolSideEffect,
    #[serde(default)]
    idempotency: ToolIdempotency,
    #[serde(default)]
    terminal: bool,
    #[serde(default)]
    capability_tags: Vec<String>,
    #[serde(default)]
    cost_dimensions: Vec<String>,
}

impl<'de> Deserialize<'de> for ToolMetadata {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let wire = ToolMetadataWire::deserialize(deserializer)?;
        Self {
            side_effect: wire.side_effect,
            idempotency: wire.idempotency,
            terminal: wire.terminal,
            capability_tags: wire.capability_tags,
            cost_dimensions: wire.cost_dimensions,
        }
        .normalized()
        .map_err(D::Error::custom)
    }
}

impl ToolMetadata {
    pub fn normalized(&self) -> Result<Self, ToolMetadataError> {
        Ok(Self {
            side_effect: self.side_effect,
            idempotency: self.idempotency,
            terminal: self.terminal,
            capability_tags: normalize_tool_metadata_labels(
                &self.capability_tags,
                "capability_tags",
            )?,
            cost_dimensions: normalize_tool_metadata_labels(
                &self.cost_dimensions,
                "cost_dimensions",
            )?,
        })
    }
}

pub(crate) fn normalize_tool_metadata_labels(
    values: &[String],
    field_name: &str,
) -> Result<Vec<String>, ToolMetadataError> {
    let mut normalized = Vec::with_capacity(values.len());
    for value in values {
        let value = trim_metadata_whitespace(value);
        if value.is_empty() {
            return Err(ToolMetadataError::new(format!(
                "{field_name} cannot contain a blank label"
            )));
        }
        if value.chars().count() > MAX_TOOL_METADATA_LABEL_CODE_POINTS {
            return Err(ToolMetadataError::new(format!(
                "{field_name} labels cannot exceed {MAX_TOOL_METADATA_LABEL_CODE_POINTS} Unicode code points"
            )));
        }
        normalized.push(value.to_string());
    }
    normalized.sort_by(|left, right| utf16_cmp(left, right));
    normalized.dedup();
    if normalized.len() > MAX_TOOL_METADATA_LABELS {
        return Err(ToolMetadataError::new(format!(
            "{field_name} cannot contain more than {MAX_TOOL_METADATA_LABELS} labels"
        )));
    }
    Ok(normalized)
}

fn trim_metadata_whitespace(value: &str) -> &str {
    value.trim_matches(|character| matches!(character, '\t' | '\n' | '\r' | ' '))
}

pub(crate) fn utf16_cmp(left: &str, right: &str) -> Ordering {
    left.encode_utf16().cmp(right.encode_utf16())
}