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, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ToolResultRetention {
40    #[default]
41    Archive,
42    Preserve,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
46pub struct ToolMetadata {
47    pub side_effect: ToolSideEffect,
48    pub idempotency: ToolIdempotency,
49    pub terminal: bool,
50    pub result_retention: ToolResultRetention,
51    pub capability_tags: Vec<String>,
52    pub cost_dimensions: Vec<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[error("tool_metadata_invalid: {message}")]
57pub struct ToolMetadataError {
58    message: String,
59}
60
61impl ToolMetadataError {
62    fn new(message: impl Into<String>) -> Self {
63        Self {
64            message: message.into(),
65        }
66    }
67
68    pub fn message(&self) -> &str {
69        &self.message
70    }
71}
72
73#[derive(Deserialize)]
74#[serde(deny_unknown_fields)]
75struct ToolMetadataWire {
76    #[serde(default)]
77    side_effect: ToolSideEffect,
78    #[serde(default)]
79    idempotency: ToolIdempotency,
80    #[serde(default)]
81    terminal: bool,
82    #[serde(default)]
83    result_retention: ToolResultRetention,
84    #[serde(default)]
85    capability_tags: Vec<String>,
86    #[serde(default)]
87    cost_dimensions: Vec<String>,
88}
89
90impl<'de> Deserialize<'de> for ToolMetadata {
91    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92    where
93        D: Deserializer<'de>,
94    {
95        let wire = ToolMetadataWire::deserialize(deserializer)?;
96        Self {
97            side_effect: wire.side_effect,
98            idempotency: wire.idempotency,
99            terminal: wire.terminal,
100            result_retention: wire.result_retention,
101            capability_tags: wire.capability_tags,
102            cost_dimensions: wire.cost_dimensions,
103        }
104        .normalized()
105        .map_err(D::Error::custom)
106    }
107}
108
109impl ToolMetadata {
110    pub fn normalized(&self) -> Result<Self, ToolMetadataError> {
111        Ok(Self {
112            side_effect: self.side_effect,
113            idempotency: self.idempotency,
114            terminal: self.terminal,
115            result_retention: self.result_retention,
116            capability_tags: normalize_tool_metadata_labels(
117                &self.capability_tags,
118                "capability_tags",
119            )?,
120            cost_dimensions: normalize_tool_metadata_labels(
121                &self.cost_dimensions,
122                "cost_dimensions",
123            )?,
124        })
125    }
126}
127
128pub(crate) fn normalize_tool_metadata_labels(
129    values: &[String],
130    field_name: &str,
131) -> Result<Vec<String>, ToolMetadataError> {
132    let mut normalized = Vec::with_capacity(values.len());
133    for value in values {
134        let value = trim_metadata_whitespace(value);
135        if value.is_empty() {
136            return Err(ToolMetadataError::new(format!(
137                "{field_name} cannot contain a blank label"
138            )));
139        }
140        if value.chars().count() > MAX_TOOL_METADATA_LABEL_CODE_POINTS {
141            return Err(ToolMetadataError::new(format!(
142                "{field_name} labels cannot exceed {MAX_TOOL_METADATA_LABEL_CODE_POINTS} Unicode code points"
143            )));
144        }
145        normalized.push(value.to_string());
146    }
147    normalized.sort_by(|left, right| utf16_cmp(left, right));
148    normalized.dedup();
149    if normalized.len() > MAX_TOOL_METADATA_LABELS {
150        return Err(ToolMetadataError::new(format!(
151            "{field_name} cannot contain more than {MAX_TOOL_METADATA_LABELS} labels"
152        )));
153    }
154    Ok(normalized)
155}
156
157fn trim_metadata_whitespace(value: &str) -> &str {
158    value.trim_matches(|character| matches!(character, '\t' | '\n' | '\r' | ' '))
159}
160
161pub(crate) fn utf16_cmp(left: &str, right: &str) -> Ordering {
162    left.encode_utf16().cmp(right.encode_utf16())
163}