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        Message::tool(content, self.tool_call_id.clone())
198    }
199
200    pub fn to_tool_message(&self) -> Message {
201        self.to_message()
202    }
203
204    pub fn validate(&self) -> Result<(), String> {
205        for forbidden in ["content", "instructions", "output", "stderr", "stdout"] {
206            if self.metadata.contains_key(forbidden) {
207                return Err(format!(
208                    "tool_result_invalid: metadata key {forbidden:?} may not repeat bulk output"
209                ));
210            }
211        }
212        if !self.truncated {
213            if self.truncation_reason.is_some()
214                || self.original_bytes.is_some()
215                || self.visible_bytes.is_some()
216                || self.artifact.is_some()
217                || self.cursor.is_some()
218            {
219                return Err(
220                    "tool_result_invalid: ordinary results cannot contain recovery fields"
221                        .to_string(),
222                );
223            }
224            return Ok(());
225        }
226
227        let reason = self.truncation_reason.ok_or_else(|| {
228            "tool_result_invalid: truncated result requires truncation_reason".to_string()
229        })?;
230        let original_bytes = self.original_bytes.ok_or_else(|| {
231            "tool_result_invalid: truncated result requires original_bytes".to_string()
232        })?;
233        let visible_bytes = self.visible_bytes.ok_or_else(|| {
234            "tool_result_invalid: truncated result requires visible_bytes".to_string()
235        })?;
236        if visible_bytes != self.content.len() as u64 || visible_bytes > original_bytes {
237            return Err(
238                "tool_result_invalid: truncated result byte counts are invalid".to_string(),
239            );
240        }
241        match reason {
242            ToolTruncationReason::OutputLimit => {
243                let artifact = self.artifact.as_ref().ok_or_else(|| {
244                    "tool_result_invalid: output_limit requires artifact".to_string()
245                })?;
246                if self.cursor.is_some() {
247                    return Err(
248                        "tool_result_invalid: output_limit cannot contain cursor".to_string()
249                    );
250                }
251                artifact.validate()?;
252            }
253            ToolTruncationReason::ReadLimit => {
254                let cursor = self
255                    .cursor
256                    .as_ref()
257                    .ok_or_else(|| "tool_result_invalid: read_limit requires cursor".to_string())?;
258                if self.artifact.is_some() {
259                    return Err(
260                        "tool_result_invalid: read_limit cannot contain artifact".to_string()
261                    );
262                }
263                cursor.validate()?;
264            }
265        }
266        Ok(())
267    }
268
269    fn recovery_value(&self) -> Value {
270        let mut recovery = serde_json::Map::new();
271        recovery.insert("truncated".to_string(), Value::Bool(true));
272        recovery.insert(
273            "truncation_reason".to_string(),
274            Value::String(
275                match self.truncation_reason.expect("validated truncated result") {
276                    ToolTruncationReason::OutputLimit => "output_limit",
277                    ToolTruncationReason::ReadLimit => "read_limit",
278                }
279                .to_string(),
280            ),
281        );
282        recovery.insert(
283            "original_bytes".to_string(),
284            Value::from(self.original_bytes.expect("validated truncated result")),
285        );
286        recovery.insert(
287            "visible_bytes".to_string(),
288            Value::from(self.visible_bytes.expect("validated truncated result")),
289        );
290        if let Some(artifact) = &self.artifact {
291            recovery.insert(
292                "artifact".to_string(),
293                serde_json::to_value(artifact).expect("ToolArtifactRef is serializable"),
294            );
295        }
296        if let Some(cursor) = &self.cursor {
297            recovery.insert(
298                "cursor".to_string(),
299                serde_json::to_value(cursor).expect("ToolResultCursor is serializable"),
300            );
301        }
302        Value::Object(serde_json::Map::from_iter([(
303            "vv_agent_recovery".to_string(),
304            Value::Object(recovery),
305        )]))
306    }
307}
308
309impl ToolArtifactRef {
310    pub fn validate(&self) -> Result<(), String> {
311        if !valid_artifact_path(&self.path) {
312            return Err("artifact_path_invalid".to_string());
313        }
314        if self.media_type != "text/plain" || self.encoding != "utf-8" {
315            return Err(
316                "tool_result_invalid: artifact media type or encoding is invalid".to_string(),
317            );
318        }
319        if !valid_sha256(&self.sha256) {
320            return Err("tool_result_invalid: artifact sha256 is invalid".to_string());
321        }
322        Ok(())
323    }
324}
325
326impl ToolResultCursor {
327    pub fn validate(&self) -> Result<(), String> {
328        if self.kind != "read_file"
329            || self.path.trim().is_empty()
330            || self.path.contains(['\\', '\0'])
331            || self.offset_chars > crate::budget::MAX_WIRE_INTEGER
332            || !valid_sha256(&self.sha256)
333        {
334            return Err("tool_result_invalid: read_file cursor is invalid".to_string());
335        }
336        Ok(())
337    }
338}
339
340fn valid_artifact_path(path: &str) -> bool {
341    const PREFIX: &str = ".vv-agent/artifacts/";
342    if path.len() > 512 || !path.starts_with(PREFIX) || path.contains(['\\', '\0']) {
343        return false;
344    }
345    path[PREFIX.len()..].split('/').all(|segment| {
346        !segment.is_empty()
347            && segment.len() <= 128
348            && segment
349                .bytes()
350                .next()
351                .is_some_and(|byte| byte.is_ascii_alphanumeric())
352            && segment
353                .bytes()
354                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
355    })
356}
357
358fn valid_sha256(value: &str) -> bool {
359    value.len() == 64
360        && value
361            .bytes()
362            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
363}