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}