Skip to main content

vv_agent/types/
tool_calls.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::{Message, Metadata, ToolArguments, ToolDirective, ToolResultStatus};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub struct ToolCall {
8    pub id: String,
9    pub name: String,
10    pub arguments: ToolArguments,
11    pub extra_content: Option<Value>,
12}
13
14impl ToolCall {
15    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: ToolArguments) -> Self {
16        Self {
17            id: id.into(),
18            name: name.into(),
19            arguments,
20            extra_content: None,
21        }
22    }
23
24    pub fn from_raw_arguments(
25        id: impl Into<String>,
26        name: impl Into<String>,
27        raw_arguments: Value,
28    ) -> Self {
29        let id = id.into();
30        let name = name.into();
31        match parse_raw_tool_arguments(&raw_arguments) {
32            Ok(arguments) => Self {
33                id,
34                name,
35                arguments,
36                extra_content: None,
37            },
38            Err((error_code, error)) => Self {
39                id,
40                name,
41                arguments: ToolArguments::new(),
42                extra_content: Some(Value::Object(
43                    [
44                        ("raw_arguments".to_string(), raw_arguments),
45                        ("argument_error_code".to_string(), Value::String(error_code)),
46                        ("argument_error".to_string(), Value::String(error)),
47                    ]
48                    .into_iter()
49                    .collect(),
50                )),
51            },
52        }
53    }
54}
55
56fn parse_raw_tool_arguments(raw_arguments: &Value) -> Result<ToolArguments, (String, String)> {
57    match raw_arguments {
58        Value::Null => Ok(ToolArguments::new()),
59        Value::Object(object) => Ok(object.clone().into_iter().collect()),
60        Value::String(raw) => {
61            let stripped = raw.trim();
62            if stripped.is_empty() {
63                return Ok(ToolArguments::new());
64            }
65            let parsed = serde_json::from_str::<Value>(stripped).map_err(|error| {
66                (
67                    "invalid_arguments_json".to_string(),
68                    format!("Invalid tool arguments JSON: {error}"),
69                )
70            })?;
71            match parsed {
72                Value::Object(object) => Ok(object.into_iter().collect()),
73                _ => Err((
74                    "invalid_arguments_payload".to_string(),
75                    "Tool arguments must decode to an object".to_string(),
76                )),
77            }
78        }
79        other => Err((
80            "invalid_arguments_type".to_string(),
81            format!("Unsupported tool argument type: {}", json_type_name(other)),
82        )),
83    }
84}
85
86fn json_type_name(value: &Value) -> &'static str {
87    match value {
88        Value::Null => "null",
89        Value::Bool(_) => "bool",
90        Value::Number(_) => "number",
91        Value::String(_) => "string",
92        Value::Array(_) => "array",
93        Value::Object(_) => "object",
94    }
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct ToolExecutionResult {
99    pub tool_call_id: String,
100    pub content: String,
101    pub status: ToolResultStatus,
102    pub directive: ToolDirective,
103    pub error_code: Option<String>,
104    pub metadata: Metadata,
105    pub image_url: Option<String>,
106    pub image_path: Option<String>,
107    pub truncated: bool,
108    pub truncation_reason: Option<ToolTruncationReason>,
109    pub original_bytes: Option<u64>,
110    pub visible_bytes: Option<u64>,
111    pub artifact: Option<ToolArtifactRef>,
112    pub cursor: Option<ToolResultCursor>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ToolTruncationReason {
118    OutputLimit,
119    ReadLimit,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct ToolArtifactRef {
125    pub path: String,
126    pub media_type: String,
127    pub encoding: String,
128    pub size_bytes: u64,
129    pub sha256: String,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct ToolResultCursor {
135    pub kind: String,
136    pub path: String,
137    pub offset_chars: u64,
138    pub sha256: String,
139}
140
141impl Serialize for ToolExecutionResult {
142    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
143    where
144        S: serde::Serializer,
145    {
146        self.validate().map_err(serde::ser::Error::custom)?;
147        self.to_dict().serialize(serializer)
148    }
149}
150
151impl<'de> Deserialize<'de> for ToolExecutionResult {
152    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
153    where
154        D: serde::Deserializer<'de>,
155    {
156        let value = Value::deserialize(deserializer)?;
157        Self::from_dict(&value).map_err(serde::de::Error::custom)
158    }
159}
160
161impl ToolExecutionResult {
162    pub fn success(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
163        Self {
164            tool_call_id: tool_call_id.into(),
165            content: content.into(),
166            status: ToolResultStatus::Success,
167            directive: ToolDirective::Continue,
168            error_code: None,
169            metadata: Metadata::new(),
170            image_url: None,
171            image_path: None,
172            truncated: false,
173            truncation_reason: None,
174            original_bytes: None,
175            visible_bytes: None,
176            artifact: None,
177            cursor: None,
178        }
179    }
180
181    pub fn error(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
182        Self {
183            status: ToolResultStatus::Error,
184            ..Self::success(tool_call_id, content)
185        }
186    }
187
188    pub fn to_message(&self) -> Message {
189        let mut content = self.content.clone();
190        if self.truncated {
191            let recovery = self.recovery_value();
192            let encoded = serde_json_canonicalizer::to_string(&recovery)
193                .expect("validated tool recovery fields are canonical JSON");
194            content.push('\n');
195            content.push_str(&encoded);
196        }
197        let mut message = Message::tool(content, self.tool_call_id.clone());
198        message.artifact_ref = self.artifact.clone();
199        if self.artifact.is_some() {
200            message.metadata.insert(
201                "_vv_agent_microcompact_excerpt".to_string(),
202                Value::String(self.content.clone()),
203            );
204        }
205        message
206    }
207
208    pub fn to_tool_message(&self) -> Message {
209        self.to_message()
210    }
211
212    pub fn validate(&self) -> Result<(), String> {
213        for forbidden in ["content", "instructions", "output", "stderr", "stdout"] {
214            if self.metadata.contains_key(forbidden) {
215                return Err(format!(
216                    "tool_result_invalid: metadata key {forbidden:?} may not repeat bulk output"
217                ));
218            }
219        }
220        if !self.truncated {
221            if self.truncation_reason.is_some()
222                || self.original_bytes.is_some()
223                || self.visible_bytes.is_some()
224                || self.artifact.is_some()
225                || self.cursor.is_some()
226            {
227                return Err(
228                    "tool_result_invalid: ordinary results cannot contain recovery fields"
229                        .to_string(),
230                );
231            }
232            return Ok(());
233        }
234
235        let reason = self.truncation_reason.ok_or_else(|| {
236            "tool_result_invalid: truncated result requires truncation_reason".to_string()
237        })?;
238        let original_bytes = self.original_bytes.ok_or_else(|| {
239            "tool_result_invalid: truncated result requires original_bytes".to_string()
240        })?;
241        let visible_bytes = self.visible_bytes.ok_or_else(|| {
242            "tool_result_invalid: truncated result requires visible_bytes".to_string()
243        })?;
244        if visible_bytes != self.content.len() as u64 || visible_bytes > original_bytes {
245            return Err(
246                "tool_result_invalid: truncated result byte counts are invalid".to_string(),
247            );
248        }
249        match reason {
250            ToolTruncationReason::OutputLimit => {
251                let artifact = self.artifact.as_ref().ok_or_else(|| {
252                    "tool_result_invalid: output_limit requires artifact".to_string()
253                })?;
254                if self.cursor.is_some() {
255                    return Err(
256                        "tool_result_invalid: output_limit cannot contain cursor".to_string()
257                    );
258                }
259                artifact.validate()?;
260            }
261            ToolTruncationReason::ReadLimit => {
262                let cursor = self
263                    .cursor
264                    .as_ref()
265                    .ok_or_else(|| "tool_result_invalid: read_limit requires cursor".to_string())?;
266                if self.artifact.is_some() {
267                    return Err(
268                        "tool_result_invalid: read_limit cannot contain artifact".to_string()
269                    );
270                }
271                cursor.validate()?;
272            }
273        }
274        Ok(())
275    }
276
277    fn recovery_value(&self) -> Value {
278        let mut recovery = serde_json::Map::new();
279        recovery.insert("truncated".to_string(), Value::Bool(true));
280        recovery.insert(
281            "truncation_reason".to_string(),
282            Value::String(
283                match self.truncation_reason.expect("validated truncated result") {
284                    ToolTruncationReason::OutputLimit => "output_limit",
285                    ToolTruncationReason::ReadLimit => "read_limit",
286                }
287                .to_string(),
288            ),
289        );
290        recovery.insert(
291            "original_bytes".to_string(),
292            Value::from(self.original_bytes.expect("validated truncated result")),
293        );
294        recovery.insert(
295            "visible_bytes".to_string(),
296            Value::from(self.visible_bytes.expect("validated truncated result")),
297        );
298        if let Some(artifact) = &self.artifact {
299            recovery.insert(
300                "artifact".to_string(),
301                serde_json::to_value(artifact).expect("ToolArtifactRef is serializable"),
302            );
303        }
304        if let Some(cursor) = &self.cursor {
305            recovery.insert(
306                "cursor".to_string(),
307                serde_json::to_value(cursor).expect("ToolResultCursor is serializable"),
308            );
309        }
310        Value::Object(serde_json::Map::from_iter([(
311            "vv_agent_recovery".to_string(),
312            Value::Object(recovery),
313        )]))
314    }
315}
316
317impl ToolArtifactRef {
318    pub fn validate(&self) -> Result<(), String> {
319        if !valid_artifact_path(&self.path) {
320            return Err("artifact_path_invalid".to_string());
321        }
322        if self.media_type != "text/plain" || self.encoding != "utf-8" {
323            return Err(
324                "tool_result_invalid: artifact media type or encoding is invalid".to_string(),
325            );
326        }
327        if !valid_sha256(&self.sha256) {
328            return Err("tool_result_invalid: artifact sha256 is invalid".to_string());
329        }
330        Ok(())
331    }
332}
333
334impl ToolResultCursor {
335    pub fn validate(&self) -> Result<(), String> {
336        if self.kind != "read_file"
337            || self.path.trim().is_empty()
338            || self.path.contains(['\\', '\0'])
339            || self.offset_chars > crate::budget::MAX_WIRE_INTEGER
340            || !valid_sha256(&self.sha256)
341        {
342            return Err("tool_result_invalid: read_file cursor is invalid".to_string());
343        }
344        Ok(())
345    }
346}
347
348fn valid_artifact_path(path: &str) -> bool {
349    const PREFIX: &str = ".vv-agent/artifacts/";
350    if path.len() > 512 || !path.starts_with(PREFIX) || path.contains(['\\', '\0']) {
351        return false;
352    }
353    path[PREFIX.len()..].split('/').all(|segment| {
354        !segment.is_empty()
355            && segment.len() <= 128
356            && segment
357                .bytes()
358                .next()
359                .is_some_and(|byte| byte.is_ascii_alphanumeric())
360            && segment
361                .bytes()
362                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
363    })
364}
365
366fn valid_sha256(value: &str) -> bool {
367    value.len() == 64
368        && value
369            .bytes()
370            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
371}