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}