syncable_cli/agent/tools/
response.rs

1//! Response formatting utilities for agent tools
2//!
3//! This module provides consistent response formatting for all agent tools.
4//! It works alongside the error utilities in `error.rs` to provide a complete
5//! response infrastructure.
6//!
7//! ## Pattern
8//!
9//! Tools should use these utilities for successful responses:
10//! 1. Use `format_success` for simple successful operations
11//! 2. Use `format_success_with_metadata` when including truncation/compression info
12//! 3. Use `format_file_content` for file read operations
13//! 4. Use `format_list` for directory listings and other lists
14//!
15//! ## Example
16//!
17//! ```ignore
18//! use crate::agent::tools::response::{format_success, format_file_content, ResponseMetadata};
19//!
20//! // Simple success response
21//! let response = format_success("read_file", json!({"content": "file contents"}));
22//!
23//! // File content response with metadata
24//! let response = format_file_content(
25//!     "src/main.rs",
26//!     &file_content,
27//!     100,  // total lines
28//!     100,  // returned lines
29//!     false, // not truncated
30//! );
31//! ```
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35
36use super::truncation::TruncationLimits;
37
38/// Metadata about a tool response
39///
40/// This provides additional context about the response, such as whether
41/// the output was truncated or the original size of the data.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct ResponseMetadata {
44    /// Whether the output was truncated to fit size limits
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub truncated: Option<bool>,
47    /// Original size of the data before truncation (in bytes or count)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub original_size: Option<usize>,
50    /// Final size after truncation
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub final_size: Option<usize>,
53    /// Number of items (for lists/arrays)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub item_count: Option<usize>,
56    /// Total items before truncation
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub total_items: Option<usize>,
59    /// Whether data was compressed/stored for retrieval
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub compressed: Option<bool>,
62    /// Reference ID for retrieving full data
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub retrieval_ref: Option<String>,
65}
66
67impl ResponseMetadata {
68    /// Create metadata for truncated output
69    pub fn truncated(original_size: usize, final_size: usize) -> Self {
70        Self {
71            truncated: Some(true),
72            original_size: Some(original_size),
73            final_size: Some(final_size),
74            ..Default::default()
75        }
76    }
77
78    /// Create metadata for a list with item counts
79    pub fn for_list(item_count: usize, total_items: usize) -> Self {
80        Self {
81            item_count: Some(item_count),
82            total_items: Some(total_items),
83            truncated: Some(item_count < total_items),
84            ..Default::default()
85        }
86    }
87
88    /// Create metadata for compressed output with retrieval reference
89    pub fn compressed(retrieval_ref: String, original_size: usize) -> Self {
90        Self {
91            compressed: Some(true),
92            retrieval_ref: Some(retrieval_ref),
93            original_size: Some(original_size),
94            ..Default::default()
95        }
96    }
97
98    /// Check if this metadata indicates any modification (truncation/compression)
99    pub fn is_modified(&self) -> bool {
100        self.truncated.unwrap_or(false) || self.compressed.unwrap_or(false)
101    }
102}
103
104/// Standard tool response structure
105///
106/// This provides a consistent response format for all tools while remaining
107/// backward compatible with existing tool outputs.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ToolResponse {
110    /// Whether the operation succeeded
111    pub success: bool,
112    /// The response data (tool-specific)
113    #[serde(flatten)]
114    pub data: Value,
115    /// Optional metadata about the response
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub metadata: Option<ResponseMetadata>,
118}
119
120impl ToolResponse {
121    /// Create a successful response with data
122    pub fn success(data: Value) -> Self {
123        Self {
124            success: true,
125            data,
126            metadata: None,
127        }
128    }
129
130    /// Create a successful response with metadata
131    pub fn success_with_metadata(data: Value, metadata: ResponseMetadata) -> Self {
132        Self {
133            success: true,
134            data,
135            metadata: Some(metadata),
136        }
137    }
138
139    /// Convert to JSON string
140    pub fn to_json(&self) -> String {
141        serde_json::to_string_pretty(self).unwrap_or_else(|_| {
142            r#"{"success": false, "error": "Failed to serialize response"}"#.to_string()
143        })
144    }
145}
146
147/// Format a simple success response
148///
149/// Use this for operations that don't need metadata about truncation/compression.
150///
151/// # Arguments
152///
153/// * `tool_name` - Name of the tool (for debugging/logging)
154/// * `data` - The response data to serialize
155///
156/// # Returns
157///
158/// JSON string of the response
159pub fn format_success<T: Serialize>(tool_name: &str, data: &T) -> String {
160    let value = serde_json::to_value(data).unwrap_or_else(|e| {
161        json!({
162            "error": true,
163            "tool": tool_name,
164            "message": format!("Failed to serialize response: {}", e)
165        })
166    });
167
168    let response = ToolResponse::success(value);
169    response.to_json()
170}
171
172/// Format a success response with metadata
173///
174/// Use this when you need to include information about truncation, compression,
175/// or item counts.
176///
177/// # Arguments
178///
179/// * `tool_name` - Name of the tool
180/// * `data` - The response data
181/// * `metadata` - Response metadata
182pub fn format_success_with_metadata<T: Serialize>(
183    tool_name: &str,
184    data: &T,
185    metadata: ResponseMetadata,
186) -> String {
187    let value = serde_json::to_value(data).unwrap_or_else(|e| {
188        json!({
189            "error": true,
190            "tool": tool_name,
191            "message": format!("Failed to serialize response: {}", e)
192        })
193    });
194
195    let response = ToolResponse::success_with_metadata(value, metadata);
196    response.to_json()
197}
198
199/// Format file content response
200///
201/// Creates a consistent response format for file read operations.
202/// This is backward compatible with the existing ReadFileTool output format.
203///
204/// # Arguments
205///
206/// * `path` - Path to the file
207/// * `content` - File content (already truncated if needed)
208/// * `total_lines` - Total lines in the original file
209/// * `returned_lines` - Number of lines actually returned
210/// * `truncated` - Whether the content was truncated
211pub fn format_file_content(
212    path: &str,
213    content: &str,
214    total_lines: usize,
215    returned_lines: usize,
216    truncated: bool,
217) -> String {
218    let data = json!({
219        "file": path,
220        "total_lines": total_lines,
221        "lines_returned": returned_lines,
222        "truncated": truncated,
223        "content": content
224    });
225
226    serde_json::to_string_pretty(&data).unwrap_or_else(|_| {
227        format!(
228            r#"{{"file": "{}", "error": "Failed to serialize content"}}"#,
229            path
230        )
231    })
232}
233
234/// Format file content response for line range
235///
236/// Creates a response for a specific line range read.
237pub fn format_file_content_range(
238    path: &str,
239    content: &str,
240    start_line: usize,
241    end_line: usize,
242    total_lines: usize,
243) -> String {
244    let data = json!({
245        "file": path,
246        "lines": format!("{}-{}", start_line, end_line),
247        "total_lines": total_lines,
248        "content": content
249    });
250
251    serde_json::to_string_pretty(&data).unwrap_or_else(|_| {
252        format!(
253            r#"{{"file": "{}", "error": "Failed to serialize content"}}"#,
254            path
255        )
256    })
257}
258
259/// Format a list/directory response
260///
261/// Creates a consistent response format for list operations (directories, search results, etc.).
262/// This is backward compatible with the existing ListDirectoryTool output format.
263///
264/// # Arguments
265///
266/// * `path` - The path that was listed (for directories) or query context
267/// * `entries` - The list of items
268/// * `total_count` - Total number of items (before truncation)
269/// * `truncated` - Whether the list was truncated
270pub fn format_list(path: &str, entries: &[Value], total_count: usize, truncated: bool) -> String {
271    let data = if truncated {
272        let limits = TruncationLimits::default();
273        json!({
274            "path": path,
275            "entries": entries,
276            "entries_returned": entries.len(),
277            "total_count": total_count,
278            "truncated": true,
279            "note": format!(
280                "Showing first {} of {} entries. Use a more specific path to see others.",
281                entries.len().min(limits.max_dir_entries),
282                total_count
283            )
284        })
285    } else {
286        json!({
287            "path": path,
288            "entries": entries,
289            "total_count": total_count
290        })
291    };
292
293    serde_json::to_string_pretty(&data).unwrap_or_else(|_| {
294        format!(
295            r#"{{"path": "{}", "error": "Failed to serialize entries"}}"#,
296            path
297        )
298    })
299}
300
301/// Format a list response with custom metadata
302///
303/// More flexible version of format_list that allows custom metadata fields.
304pub fn format_list_with_metadata(
305    entries: &[Value],
306    metadata: ResponseMetadata,
307    extra_fields: &[(&str, Value)],
308) -> String {
309    let mut data = json!({
310        "entries": entries,
311    });
312
313    // Add extra fields
314    if let Some(obj) = data.as_object_mut() {
315        for (key, value) in extra_fields {
316            obj.insert((*key).to_string(), value.clone());
317        }
318
319        // Add metadata fields directly (flattened)
320        if let Some(truncated) = metadata.truncated {
321            obj.insert("truncated".to_string(), json!(truncated));
322        }
323        if let Some(total) = metadata.total_items {
324            obj.insert("total_count".to_string(), json!(total));
325        }
326        if let Some(count) = metadata.item_count {
327            obj.insert("entries_returned".to_string(), json!(count));
328        }
329    }
330
331    serde_json::to_string_pretty(&data)
332        .unwrap_or_else(|_| r#"{"error": "Failed to serialize list response"}"#.to_string())
333}
334
335/// Format a write operation response
336///
337/// Creates a consistent response for file/resource write operations.
338pub fn format_write_success(
339    path: &str,
340    action: &str,
341    lines_written: usize,
342    bytes_written: usize,
343) -> String {
344    let data = json!({
345        "success": true,
346        "action": action,
347        "path": path,
348        "lines_written": lines_written,
349        "bytes_written": bytes_written
350    });
351
352    serde_json::to_string_pretty(&data).unwrap_or_else(|_| {
353        format!(
354            r#"{{"success": true, "action": "{}", "path": "{}"}}"#,
355            action, path
356        )
357    })
358}
359
360/// Format a cancelled operation response
361///
362/// Creates a response indicating the operation was cancelled by the user.
363pub fn format_cancelled(path: &str, reason: &str, feedback: Option<&str>) -> String {
364    let mut data = json!({
365        "cancelled": true,
366        "STOP": "User has rejected this operation. Do NOT create this file or any alternative files.",
367        "reason": reason,
368        "original_path": path,
369        "action_required": "Stop creating files. Ask the user what they want instead."
370    });
371
372    if let Some(fb) = feedback {
373        data["user_feedback"] = json!(fb);
374        data["STOP"] =
375            json!("Do NOT create this file or any similar files. Wait for user instruction.");
376        data["action_required"] = json!(
377            "Read the user_feedback and respond accordingly. Do NOT try to create alternative files."
378        );
379    }
380
381    serde_json::to_string_pretty(&data)
382        .unwrap_or_else(|_| format!(r#"{{"cancelled": true, "reason": "{}"}}"#, reason))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_response_metadata_truncated() {
391        let meta = ResponseMetadata::truncated(1000, 500);
392        assert_eq!(meta.truncated, Some(true));
393        assert_eq!(meta.original_size, Some(1000));
394        assert_eq!(meta.final_size, Some(500));
395        assert!(meta.is_modified());
396    }
397
398    #[test]
399    fn test_response_metadata_for_list() {
400        let meta = ResponseMetadata::for_list(10, 100);
401        assert_eq!(meta.item_count, Some(10));
402        assert_eq!(meta.total_items, Some(100));
403        assert_eq!(meta.truncated, Some(true));
404    }
405
406    #[test]
407    fn test_response_metadata_compressed() {
408        let meta = ResponseMetadata::compressed("ref-123".to_string(), 50000);
409        assert_eq!(meta.compressed, Some(true));
410        assert_eq!(meta.retrieval_ref, Some("ref-123".to_string()));
411        assert!(meta.is_modified());
412    }
413
414    #[test]
415    fn test_format_file_content() {
416        let response = format_file_content("test.rs", "fn main() {}", 10, 10, false);
417        let parsed: Value = serde_json::from_str(&response).unwrap();
418
419        assert_eq!(parsed["file"], "test.rs");
420        assert_eq!(parsed["total_lines"], 10);
421        assert_eq!(parsed["lines_returned"], 10);
422        assert_eq!(parsed["truncated"], false);
423        assert_eq!(parsed["content"], "fn main() {}");
424    }
425
426    #[test]
427    fn test_format_file_content_truncated() {
428        let response = format_file_content("large.rs", "content...", 5000, 2000, true);
429        let parsed: Value = serde_json::from_str(&response).unwrap();
430
431        assert_eq!(parsed["truncated"], true);
432        assert_eq!(parsed["total_lines"], 5000);
433        assert_eq!(parsed["lines_returned"], 2000);
434    }
435
436    #[test]
437    fn test_format_list() {
438        let entries = vec![
439            json!({"name": "file1.rs", "type": "file"}),
440            json!({"name": "file2.rs", "type": "file"}),
441        ];
442
443        let response = format_list("src/", &entries, 2, false);
444        let parsed: Value = serde_json::from_str(&response).unwrap();
445
446        assert_eq!(parsed["path"], "src/");
447        assert_eq!(parsed["total_count"], 2);
448        assert!(parsed["entries"].is_array());
449        // No truncated field when not truncated
450        assert!(parsed.get("truncated").is_none());
451    }
452
453    #[test]
454    fn test_format_list_truncated() {
455        let entries: Vec<Value> = (0..10)
456            .map(|i| json!({"name": format!("file{}.rs", i)}))
457            .collect();
458
459        let response = format_list("src/", &entries, 100, true);
460        let parsed: Value = serde_json::from_str(&response).unwrap();
461
462        assert_eq!(parsed["truncated"], true);
463        assert_eq!(parsed["total_count"], 100);
464        assert_eq!(parsed["entries_returned"], 10);
465        assert!(parsed["note"].as_str().unwrap().contains("100 entries"));
466    }
467
468    #[test]
469    fn test_format_write_success() {
470        let response = format_write_success("Dockerfile", "Created", 25, 500);
471        let parsed: Value = serde_json::from_str(&response).unwrap();
472
473        assert_eq!(parsed["success"], true);
474        assert_eq!(parsed["action"], "Created");
475        assert_eq!(parsed["path"], "Dockerfile");
476        assert_eq!(parsed["lines_written"], 25);
477        assert_eq!(parsed["bytes_written"], 500);
478    }
479
480    #[test]
481    fn test_format_cancelled() {
482        let response = format_cancelled("test.txt", "User cancelled the operation", None);
483        let parsed: Value = serde_json::from_str(&response).unwrap();
484
485        assert_eq!(parsed["cancelled"], true);
486        assert!(parsed["STOP"].as_str().unwrap().contains("rejected"));
487    }
488
489    #[test]
490    fn test_format_cancelled_with_feedback() {
491        let response = format_cancelled(
492            "test.txt",
493            "User requested changes",
494            Some("Please add comments"),
495        );
496        let parsed: Value = serde_json::from_str(&response).unwrap();
497
498        assert_eq!(parsed["cancelled"], true);
499        assert_eq!(parsed["user_feedback"], "Please add comments");
500        assert!(
501            parsed["action_required"]
502                .as_str()
503                .unwrap()
504                .contains("user_feedback")
505        );
506    }
507
508    #[test]
509    fn test_tool_response_success() {
510        let data = json!({"message": "Operation completed"});
511        let response = ToolResponse::success(data);
512
513        assert!(response.success);
514        assert!(response.metadata.is_none());
515
516        let json = response.to_json();
517        let parsed: Value = serde_json::from_str(&json).unwrap();
518        assert_eq!(parsed["success"], true);
519        assert_eq!(parsed["message"], "Operation completed");
520    }
521
522    #[test]
523    fn test_tool_response_with_metadata() {
524        let data = json!({"items": [1, 2, 3]});
525        let metadata = ResponseMetadata::for_list(3, 100);
526        let response = ToolResponse::success_with_metadata(data, metadata);
527
528        assert!(response.success);
529        assert!(response.metadata.is_some());
530
531        let json = response.to_json();
532        let parsed: Value = serde_json::from_str(&json).unwrap();
533        assert_eq!(parsed["success"], true);
534        assert_eq!(parsed["metadata"]["truncated"], true);
535    }
536}