vv_agent/tools/
metadata.rs1use 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}