Skip to main content

vv_agent/tools/
metadata.rs

1use std::cmp::Ordering;
2
3use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
4
5use crate::checkpoint::ToolIdempotency;
6
7pub const MAX_TOOL_METADATA_LABELS: usize = 32;
8pub const MAX_TOOL_METADATA_LABEL_CODE_POINTS: usize = 128;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ToolSideEffect {
13    #[default]
14    Unknown,
15    None,
16    Read,
17    Write,
18    Execute,
19    Network,
20    External,
21}
22
23impl ToolSideEffect {
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::Unknown => "unknown",
27            Self::None => "none",
28            Self::Read => "read",
29            Self::Write => "write",
30            Self::Execute => "execute",
31            Self::Network => "network",
32            Self::External => "external",
33        }
34    }
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
38pub struct ToolMetadata {
39    pub side_effect: ToolSideEffect,
40    pub idempotency: ToolIdempotency,
41    pub terminal: bool,
42    pub capability_tags: Vec<String>,
43    pub cost_dimensions: Vec<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47#[error("tool_metadata_invalid: {message}")]
48pub struct ToolMetadataError {
49    message: String,
50}
51
52impl ToolMetadataError {
53    fn new(message: impl Into<String>) -> Self {
54        Self {
55            message: message.into(),
56        }
57    }
58
59    pub fn message(&self) -> &str {
60        &self.message
61    }
62}
63
64#[derive(Deserialize)]
65#[serde(deny_unknown_fields)]
66struct ToolMetadataWire {
67    #[serde(default)]
68    side_effect: ToolSideEffect,
69    #[serde(default)]
70    idempotency: ToolIdempotency,
71    #[serde(default)]
72    terminal: bool,
73    #[serde(default)]
74    capability_tags: Vec<String>,
75    #[serde(default)]
76    cost_dimensions: Vec<String>,
77}
78
79impl<'de> Deserialize<'de> for ToolMetadata {
80    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
81    where
82        D: Deserializer<'de>,
83    {
84        let wire = ToolMetadataWire::deserialize(deserializer)?;
85        Self {
86            side_effect: wire.side_effect,
87            idempotency: wire.idempotency,
88            terminal: wire.terminal,
89            capability_tags: wire.capability_tags,
90            cost_dimensions: wire.cost_dimensions,
91        }
92        .normalized()
93        .map_err(D::Error::custom)
94    }
95}
96
97impl ToolMetadata {
98    pub fn normalized(&self) -> Result<Self, ToolMetadataError> {
99        Ok(Self {
100            side_effect: self.side_effect,
101            idempotency: self.idempotency,
102            terminal: self.terminal,
103            capability_tags: normalize_tool_metadata_labels(
104                &self.capability_tags,
105                "capability_tags",
106            )?,
107            cost_dimensions: normalize_tool_metadata_labels(
108                &self.cost_dimensions,
109                "cost_dimensions",
110            )?,
111        })
112    }
113}
114
115pub(crate) fn normalize_tool_metadata_labels(
116    values: &[String],
117    field_name: &str,
118) -> Result<Vec<String>, ToolMetadataError> {
119    let mut normalized = Vec::with_capacity(values.len());
120    for value in values {
121        let value = trim_metadata_whitespace(value);
122        if value.is_empty() {
123            return Err(ToolMetadataError::new(format!(
124                "{field_name} cannot contain a blank label"
125            )));
126        }
127        if value.chars().count() > MAX_TOOL_METADATA_LABEL_CODE_POINTS {
128            return Err(ToolMetadataError::new(format!(
129                "{field_name} labels cannot exceed {MAX_TOOL_METADATA_LABEL_CODE_POINTS} Unicode code points"
130            )));
131        }
132        normalized.push(value.to_string());
133    }
134    normalized.sort_by(|left, right| utf16_cmp(left, right));
135    normalized.dedup();
136    if normalized.len() > MAX_TOOL_METADATA_LABELS {
137        return Err(ToolMetadataError::new(format!(
138            "{field_name} cannot contain more than {MAX_TOOL_METADATA_LABELS} labels"
139        )));
140    }
141    Ok(normalized)
142}
143
144fn trim_metadata_whitespace(value: &str) -> &str {
145    value.trim_matches(|character| matches!(character, '\t' | '\n' | '\r' | ' '))
146}
147
148pub(crate) fn utf16_cmp(left: &str, right: &str) -> Ordering {
149    left.encode_utf16().cmp(right.encode_utf16())
150}