diff --git a/crates/chat-cli/src/cli/chat/message.rs b/crates/chat-cli/src/cli/chat/message.rs index be92bef619..2ddd1b1e3e 100644 --- a/crates/chat-cli/src/cli/chat/message.rs +++ b/crates/chat-cli/src/cli/chat/message.rs @@ -255,6 +255,7 @@ impl From for ToolUseResultBlock { OutputKind::Text(text) => Self::Text(text), OutputKind::Json(value) => Self::Json(value), OutputKind::Images(_) => Self::Text("See images data supplied".to_string()), + OutputKind::Mixed { text, .. } => ToolUseResultBlock::Text(text), } } } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index e0f00224b0..35db6e0257 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -370,6 +370,8 @@ const TRUST_ALL_TEXT: &str = color_print::cstr! {"All tools are now trus const TOOL_BULLET: &str = " ● "; const CONTINUATION_LINE: &str = " ⋮ "; const PURPOSE_ARROW: &str = " ↳ "; +const SUCCESS_TICK: &str = " ✓ "; +const ERROR_EXCLAMATION: &str = " ❗ "; /// Enum used to denote the origin of a tool use event enum ToolUseStatus { @@ -1521,6 +1523,10 @@ impl ChatSession { OutputKind::Images(ref image) => { image_blocks.extend(image.clone()); }, + OutputKind::Mixed { ref text, ref images } => { + debug!("Output is Mixed: text = {:?}, images = {}", text, images.len()); + image_blocks.extend(images.clone()); + }, } debug!("tool result output: {:#?}", result); diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 00ad936b83..ea52605a53 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -6,7 +6,6 @@ use crossterm::queue; use crossterm::style::{ self, Color, - Stylize, }; use eyre::{ Result, @@ -30,6 +29,7 @@ use super::{ sanitize_path_tool_arg, }; use crate::cli::chat::CONTINUATION_LINE; +use crate::cli::chat::tools::display_purpose; use crate::cli::chat::util::images::{ handle_images_from_paths, is_supported_image_type, @@ -37,12 +37,16 @@ use crate::cli::chat::util::images::{ }; use crate::os::Os; -const CHECKMARK: &str = "✔"; -const CROSS: &str = "✘"; +#[derive(Debug, Clone, Deserialize)] +pub struct FsRead { + // For batch operations + pub operations: Vec, + pub summary: Option, +} #[derive(Debug, Clone, Deserialize)] #[serde(tag = "mode")] -pub enum FsRead { +pub enum FsReadOperation { Line(FsLine), Directory(FsDirectory), Search(FsSearch), @@ -50,30 +54,159 @@ pub enum FsRead { } impl FsRead { + pub async fn validate(&mut self, os: &Os) -> Result<()> { + if self.operations.is_empty() { + bail!("At least one operation must be provided"); + } + for op in &mut self.operations { + op.validate(os).await?; + } + Ok(()) + } + + pub async fn queue_description(&self, os: &Os, updates: &mut impl Write) -> Result<()> { + if self.operations.len() == 1 { + // Single operation - display without batch prefix + self.operations[0].queue_description(os, updates).await + } else { + // Multiple operations - display as batch + queue!( + updates, + style::Print("Batch fs_read operation with "), + style::SetForegroundColor(Color::Green), + style::Print(self.operations.len()), + style::ResetColor, + style::Print(" operations:\n") + )?; + + // Display purpose if available for batch operations + let _ = display_purpose(self.summary.as_ref(), updates); + + for (i, op) in self.operations.iter().enumerate() { + queue!(updates, style::Print(format!("\n↱ Operation {}: ", i + 1)))?; + op.queue_description(os, updates).await?; + } + Ok(()) + } + } + + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { + if self.operations.len() == 1 { + // Single operation - return result directly + self.operations[0].invoke(os, updates).await + } else { + // Multiple operations - combine results + let mut combined_results = Vec::new(); + let mut all_images = Vec::new(); + let mut has_non_image_ops = false; + let mut success_ops = 0usize; + let mut failed_ops = 0usize; + + for (i, op) in self.operations.iter().enumerate() { + match op.invoke(os, updates).await { + Ok(result) => { + success_ops += 1; + + match &result.output { + OutputKind::Text(text) => { + combined_results.push(format!("=== Operation {} Result (Text) ===\n{}", i + 1, text)); + has_non_image_ops = true; + }, + OutputKind::Json(json) => { + combined_results.push(format!( + "=== Operation {} Result (Json) ===\n{}", + i + 1, + serde_json::to_string_pretty(json)? + )); + has_non_image_ops = true; + }, + OutputKind::Images(images) => { + all_images.extend(images.clone()); + combined_results.push(format!( + "=== Operation {} Result (Images) ===\n[{} images processed]", + i + 1, + images.len() + )); + }, + // This branch won't be reached because single operation execution never returns a Mixed + // result + OutputKind::Mixed { text: _, images: _ } => {}, + } + }, + + Err(err) => { + failed_ops += 1; + combined_results.push(format!("=== Operation {} Error ===\n{}", i + 1, err)); + }, + } + } + + queue!( + updates, + style::Print("\n"), + style::Print(CONTINUATION_LINE), + style::Print("\n") + )?; + super::queue_function_result( + &format!( + "Summary: {} operations processed, {} successful, {} failed", + self.operations.len(), + success_ops, + failed_ops + ), + updates, + false, + true, + )?; + + let combined_text = combined_results.join("\n\n"); + + if !all_images.is_empty() && has_non_image_ops { + queue!(updates, style::Print("\nherherherherherh"),)?; + Ok(InvokeOutput { + output: OutputKind::Mixed { + text: combined_text, + images: all_images, + }, + }) + } else if !all_images.is_empty() { + Ok(InvokeOutput { + output: OutputKind::Images(all_images), + }) + } else { + Ok(InvokeOutput { + output: OutputKind::Text(combined_text), + }) + } + } + } +} + +impl FsReadOperation { pub async fn validate(&mut self, os: &Os) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.validate(os).await, - FsRead::Directory(fs_directory) => fs_directory.validate(os).await, - FsRead::Search(fs_search) => fs_search.validate(os).await, - FsRead::Image(fs_image) => fs_image.validate(os).await, + FsReadOperation::Line(fs_line) => fs_line.validate(os).await, + FsReadOperation::Directory(fs_directory) => fs_directory.validate(os).await, + FsReadOperation::Search(fs_search) => fs_search.validate(os).await, + FsReadOperation::Image(fs_image) => fs_image.validate(os).await, } } pub async fn queue_description(&self, os: &Os, updates: &mut impl Write) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.queue_description(os, updates).await, - FsRead::Directory(fs_directory) => fs_directory.queue_description(updates), - FsRead::Search(fs_search) => fs_search.queue_description(updates), - FsRead::Image(fs_image) => fs_image.queue_description(updates), + FsReadOperation::Line(fs_line) => fs_line.queue_description(os, updates).await, + FsReadOperation::Directory(fs_directory) => fs_directory.queue_description(updates), + FsReadOperation::Search(fs_search) => fs_search.queue_description(updates), + FsReadOperation::Image(fs_image) => fs_image.queue_description(updates), } } pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { match self { - FsRead::Line(fs_line) => fs_line.invoke(os, updates).await, - FsRead::Directory(fs_directory) => fs_directory.invoke(os, updates).await, - FsRead::Search(fs_search) => fs_search.invoke(os, updates).await, - FsRead::Image(fs_image) => fs_image.invoke(updates).await, + FsReadOperation::Line(fs_line) => fs_line.invoke(os, updates).await, + FsReadOperation::Directory(fs_directory) => fs_directory.invoke(os, updates).await, + FsReadOperation::Search(fs_search) => fs_search.invoke(os, updates).await, + FsReadOperation::Image(fs_image) => fs_image.invoke(updates).await, } } } @@ -107,6 +240,7 @@ impl FsImage { pub async fn invoke(&self, updates: &mut impl Write) -> Result { let pre_processed_paths: Vec = self.image_paths.iter().map(|path| pre_process(path)).collect(); let valid_images = handle_images_from_paths(updates, &pre_processed_paths); + super::queue_function_result("Successfully read image", updates, false, false)?; Ok(InvokeOutput { output: OutputKind::Images(valid_images), }) @@ -115,9 +249,10 @@ impl FsImage { pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { queue!( updates, - style::Print("Reading images: \n"), + style::Print("Reading images: "), style::SetForegroundColor(Color::Green), style::Print(&self.image_paths.join("\n")), + style::Print("\n"), style::ResetColor, )?; Ok(()) @@ -188,7 +323,7 @@ impl FsLine { } } - pub async fn invoke(&self, os: &Os, _updates: &mut impl Write) -> Result { + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { let path = sanitize_path_tool_arg(os, &self.path); debug!(?path, "Reading"); let file_bytes = os.fs.read(&path).await?; @@ -227,6 +362,17 @@ time. You tried to read {byte_count} bytes. Try executing with fewer lines speci ); } + super::queue_function_result( + &format!( + "Successfully read {} bytes from {}", + file_contents.len(), + &path.display() + ), + updates, + false, + false, + )?; + Ok(InvokeOutput { output: OutputKind::Text(file_contents), }) @@ -280,7 +426,6 @@ impl FsSearch { style::SetForegroundColor(Color::Green), style::Print(&self.pattern.to_lowercase()), style::ResetColor, - style::Print("\n"), )?; Ok(()) } @@ -320,36 +465,17 @@ impl FsSearch { }); } } - let match_text = if total_matches == 1 { - "1 match".to_string() - } else { - format!("{} matches", total_matches) - }; - let color = if total_matches == 0 { - Color::Yellow - } else { - Color::Green - }; - - let result = if total_matches == 0 { - CROSS.yellow() - } else { - CHECKMARK.green() - }; - - queue!( + super::queue_function_result( + &format!( + "Found {} matches for pattern '{}' in {}", + total_matches, + pattern, + &file_path.display() + ), updates, - style::SetForegroundColor(Color::Yellow), - style::ResetColor, - style::Print(CONTINUATION_LINE), - style::Print("\n"), - style::Print(" "), - style::Print(result), - style::Print(" Found: "), - style::SetForegroundColor(color), - style::Print(match_text), - style::ResetColor, + false, + false, )?; Ok(InvokeOutput { @@ -400,13 +526,13 @@ impl FsDirectory { )?) } - pub async fn invoke(&self, os: &Os, _updates: &mut impl Write) -> Result { + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { let path = sanitize_path_tool_arg(os, &self.path); let max_depth = self.depth(); debug!(?path, max_depth, "Reading directory at path with depth"); let mut result = Vec::new(); let mut dir_queue = VecDeque::new(); - dir_queue.push_back((path, 0)); + dir_queue.push_back((path.clone(), 0)); while let Some((path, depth)) = dir_queue.pop_front() { if depth > max_depth { break; @@ -484,6 +610,17 @@ impl FsDirectory { ); } + super::queue_function_result( + &format!( + "Successfully read directory {} ({} entries)", + &path.display(), + file_count + ), + updates, + false, + false, + )?; + Ok(InvokeOutput { output: OutputKind::Text(result), }) @@ -563,28 +700,49 @@ mod tests { #[test] fn test_fs_read_deser() { - serde_json::from_value::(serde_json::json!({ "path": "/test_file.txt", "mode": "Line" })).unwrap(); + // Test single operations (wrapped in operations array) + serde_json::from_value::( + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line" }] }), + ) + .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "end_line": 5 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "end_line": 5 }] }), ) .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "start_line": -1 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "start_line": -1 }] }), ) .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "start_line": None:: }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "start_line": None:: }] }), ) .unwrap(); - serde_json::from_value::(serde_json::json!({ "path": "/", "mode": "Directory" })).unwrap(); + serde_json::from_value::(serde_json::json!({ "operations": [{ "path": "/", "mode": "Directory" }] })) + .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Directory", "depth": 2 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Directory", "depth": 2 }] }), ) .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Search", "pattern": "hello" }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Search", "pattern": "hello" }] }), ) .unwrap(); + serde_json::from_value::(serde_json::json!({ + "operations": [{ "image_paths": ["/img1.png", "/img2.jpg"], "mode": "Image" }] + })) + .unwrap(); + + // Test mixed batch operations + serde_json::from_value::(serde_json::json!({ + "operations": [ + { "path": "/file.txt", "mode": "Line" }, + { "path": "/dir", "mode": "Directory", "depth": 1 }, + { "path": "/log.txt", "mode": "Search", "pattern": "warning" }, + { "image_paths": ["/photo.jpg"], "mode": "Image" } + ], + "purpose": "Comprehensive file analysis" + })) + .unwrap(); } #[tokio::test] @@ -596,10 +754,11 @@ mod tests { macro_rules! assert_lines { ($start_line:expr, $end_line:expr, $expected:expr) => { let v = serde_json::json!({ + "operations": [{ "path": TEST_FILE_PATH, "mode": "Line", "start_line": $start_line, - "end_line": $end_line, + "end_line": $end_line,}] }); let output = serde_json::from_value::(v) .unwrap() @@ -629,11 +788,11 @@ mod tests { let os = setup_test_directory().await; let mut stdout = std::io::stdout(); let v = serde_json::json!({ + "operations": [{ "path": TEST_FILE_PATH, "mode": "Line", "start_line": 100, - "end_line": None::, - }); + "end_line": None::,}]}); assert!( serde_json::from_value::(v) .unwrap() @@ -664,9 +823,10 @@ mod tests { // Testing without depth let v = serde_json::json!({ + "operations": [{ "mode": "Directory", "path": "/", - }); + }]}); let output = serde_json::from_value::(v) .unwrap() .invoke(&os, &mut stdout) @@ -681,9 +841,10 @@ mod tests { // Testing with depth level 1 let v = serde_json::json!({ + "operations": [{ "mode": "Directory", "path": "/", - "depth": 1, + "depth": 1,}] }); let output = serde_json::from_value::(v) .unwrap() @@ -726,9 +887,10 @@ mod tests { } let matches = invoke_search!({ + "operations": [{ "mode": "Search", "path": TEST_FILE_PATH, - "pattern": "hello", + "pattern": "hello",}] }); assert_eq!(matches.len(), 2); assert_eq!(matches[0].line_number, 1); @@ -753,8 +915,9 @@ mod tests { os.fs.write(binary_file_path, &binary_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": binary_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -784,8 +947,9 @@ mod tests { os.fs.write(latin1_file_path, &latin1_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": latin1_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -819,9 +983,10 @@ mod tests { os.fs.write(mixed_file_path, &mixed_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": mixed_file_path, - "pattern": "hello" + "pattern": "hello"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -842,9 +1007,10 @@ mod tests { } let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": mixed_file_path, - "pattern": "goodbye" + "pattern": "goodbye"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -879,8 +1045,9 @@ mod tests { os.fs.write(windows1252_file_path, &windows1252_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": windows1252_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -917,9 +1084,10 @@ mod tests { .unwrap(); let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": invalid_utf8_file_path, - "pattern": "caf" + "pattern": "caf"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -947,8 +1115,9 @@ mod tests { os.fs.write(invalid_only_file_path, &invalid_only_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": invalid_only_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -964,9 +1133,10 @@ mod tests { } let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": invalid_only_file_path, - "pattern": "test" + "pattern": "test"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -985,4 +1155,64 @@ mod tests { panic!("expected Text output"); } } + + #[tokio::test] + async fn test_fs_read_batch_mixed_operations() { + let os = setup_test_directory().await; + let mut stdout = Vec::new(); + + let v = serde_json::json!({ + "operations": [ + { "path": TEST_FILE_PATH, "mode": "Line", "start_line": 1, "end_line": 2 }, + { "path": "/", "mode": "Directory" }, + { "path": TEST_FILE_PATH, "mode": "Search", "pattern": "hello" } + ], + "purpose": "Test mixed text operations" + }); + + let output = serde_json::from_value::(v) + .unwrap() + .invoke(&os, &mut stdout) + .await + .unwrap(); + // All text operations should return combined text + if let OutputKind::Text(text) = output.output { + // Check all operations are included + assert!(text.contains("=== Operation 1 Result (Text) ===")); + assert!(text.contains("=== Operation 2 Result (Text) ===")); + assert!(text.contains("=== Operation 3 Result (Text) ===")); + + // Check operation 1 (Line mode) + assert!(text.contains("Hello world!")); + assert!(text.contains("This is line 2")); + + // Check operation 2 (Directory mode) + assert!(text.contains("test_file.txt")); + + // Check operation 3 (Search mode) + assert!(text.contains("\"line_number\":1")); + } else { + panic!("expected text output for batch operations"); + } + } + #[tokio::test] + async fn test_fs_read_empty_operations() { + let os = Os::new().await.unwrap(); + + // Test empty operations array + let v = serde_json::json!({ + "operations": [] + }); + + let mut fs_read = serde_json::from_value::(v).unwrap(); + let result = fs_read.validate(&os).await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("At least one operation must be provided") + ); + } } diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index f45bcaba47..7ec3602dea 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -327,6 +327,7 @@ impl InvokeOutput { OutputKind::Text(s) => s.as_str(), OutputKind::Json(j) => j.as_str().unwrap_or_default(), OutputKind::Images(_) => "", + OutputKind::Mixed { text, .. } => text.as_str(), // Return the text part } } } @@ -337,6 +338,7 @@ pub enum OutputKind { Text(String), Json(serde_json::Value), Images(RichImageBlocks), + Mixed { text: String, images: RichImageBlocks }, } impl Default for OutputKind { @@ -438,6 +440,50 @@ pub fn display_purpose(purpose: Option<&String>, updates: &mut impl Write) -> Re Ok(()) } +/// Helper function to format function results with consistent styling +/// +/// # Parameters +/// * `result` - The result text to display +/// * `updates` - The output to write to +/// * `is_error` - Whether this is an error message (changes formatting) +/// * `use_bullet` - Whether to use a bullet point instead of a tick/exclamation +pub fn queue_function_result(result: &str, updates: &mut impl Write, is_error: bool, use_bullet: bool) -> Result<()> { + let lines = result.lines().collect::>(); + + // Determine symbol and color + let (symbol, color) = match (is_error, use_bullet) { + (true, _) => (super::ERROR_EXCLAMATION, Color::Red), + (false, true) => (super::TOOL_BULLET, Color::Reset), + (false, false) => (super::SUCCESS_TICK, Color::Green), + }; + + queue!(updates, style::Print("\n"))?; + + // Print first line with symbol + if let Some(first_line) = lines.first() { + queue!( + updates, + style::SetForegroundColor(color), + style::Print(symbol), + style::ResetColor, + style::Print(first_line), + style::Print("\n"), + )?; + } + + // Print remaining lines with indentation + for line in lines.iter().skip(1) { + queue!( + updates, + style::Print(" "), // 3 spaces for alignment + style::Print(line), + style::Print("\n"), + )?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use std::path::MAIN_SEPARATOR; diff --git a/crates/chat-cli/src/cli/chat/tools/tool_index.json b/crates/chat-cli/src/cli/chat/tools/tool_index.json index 448e4c8929..067e5df1ec 100644 --- a/crates/chat-cli/src/cli/chat/tools/tool_index.json +++ b/crates/chat-cli/src/cli/chat/tools/tool_index.json @@ -23,62 +23,83 @@ "description": "A brief explanation of what the command does" } }, - "required": ["command"] + "required": [ + "command" + ] } }, "fs_read": { "name": "fs_read", - "description": "Tool for reading files (for example, `cat -n`), directories (for example, `ls -la`) and images. If user has supplied paths that appear to be leading to images, you should use this tool right away using Image mode. The behavior of this tool is determined by the `mode` parameter. The available modes are:\n- line: Show lines in a file, given by an optional `start_line` and optional `end_line`.\n- directory: List directory contents. Content is returned in the \"long format\" of ls (that is, `ls -la`).\n- search: Search for a pattern in a file. The pattern is a string. The matching is case insensitive.\n\nExample Usage:\n1. Read all lines from a file: command=\"line\", path=\"/path/to/file.txt\"\n2. Read the last 5 lines from a file: command=\"line\", path=\"/path/to/file.txt\", start_line=-5\n3. List the files in the home directory: command=\"line\", path=\"~\"\n4. Recursively list files in a directory to a max depth of 2: command=\"line\", path=\"/path/to/directory\", depth=2\n5. Search for all instances of \"test\" in a file: command=\"search\", path=\"/path/to/file.txt\", pattern=\"test\"\n", + "description": "Tool for reading files, directories and images. Always provide an 'operations' array.\n\nFor single operation: provide array with one element.\nFor batch operations: provide array with multiple elements.\n\nAvailable modes:\n- Line: Read lines from a file\n- Directory: List directory contents\n- Search: Search for patterns in files\n- Image: Read and process images\n\nExamples:\n1. Single: {\"operations\": [{\"mode\": \"Line\", \"path\": \"/file.txt\"}]}\n2. Batch: {\"operations\": [{\"mode\": \"Line\", \"path\": \"/file1.txt\"}, {\"mode\": \"Search\", \"path\": \"/file2.txt\", \"pattern\": \"test\"}]}", "input_schema": { "type": "object", "properties": { - "path": { - "description": "Path to the file or directory. The path should be absolute, or otherwise start with ~ for the user's home.", - "type": "string" - }, - "image_paths": { - "description": "List of paths to the images. This is currently supported by the Image mode.", + "operations": { "type": "array", + "description": "Array of operations to execute. Provide one element for single operation, multiple for batch.", "items": { - "type": "string" - } - }, - "mode": { - "type": "string", - "enum": [ - "Line", - "Directory", - "Search", - "Image" - ], - "description": "The mode to run in: `Line`, `Directory`, `Search`. `Line` and `Search` are only for text files, and `Directory` is only for directories. `Image` is for image files, in this mode `image_paths` is required." - }, - "start_line": { - "type": "integer", - "description": "Starting line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": 1 - }, - "end_line": { - "type": "integer", - "description": "Ending line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": -1 + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "Line", + "Directory", + "Search", + "Image" + ], + "description": "The operation mode to run in: `Line`, `Directory`, `Search`. `Line` and `Search` are only for text files, and `Directory` is only for directories. `Image` is for image files, in this mode `image_paths` is required." + }, + "path": { + "type": "string", + "description": "Path to the file or directory. The path should be absolute, or otherwise start with ~ for the user's home (required for Line, Directory, Search modes)." + }, + "image_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of paths to the images. This is currently supported by the Image mode." + }, + "start_line": { + "type": "integer", + "description": "Starting line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", + "default": 1 + }, + "end_line": { + "type": "integer", + "description": "Ending line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", + "default": -1 + }, + "pattern": { + "type": "string", + "description": "Pattern to search for (required, for Search mode). Case insensitive. The pattern matching is performed per line." + }, + "context_lines": { + "type": "integer", + "description": "Number of context lines around search results (optional, for Search mode)", + "default": 2 + }, + "depth": { + "type": "integer", + "description": "Depth of a recursive directory listing (optional, for Directory mode)", + "default": 0 + } + }, + "required": [ + "mode" + ] + }, + "minItems": 1 }, - "pattern": { + "summary": { "type": "string", - "description": "Pattern to search for (required, for Search mode). Case insensitive. The pattern matching is performed per line." - }, - "context_lines": { - "type": "integer", - "description": "Number of context lines around search results (optional, for Search mode)", - "default": 2 - }, - "depth": { - "type": "integer", - "description": "Depth of a recursive directory listing (optional, for Directory mode)", - "default": 0 + "description": "Optional description of the purpose of this batch operation (mainly useful for multiple operations)" } }, - "required": ["path", "mode"] + "required": [ + "operations" + ] } }, "fs_write": { @@ -89,7 +110,12 @@ "properties": { "command": { "type": "string", - "enum": ["create", "str_replace", "insert", "append"], + "enum": [ + "create", + "str_replace", + "insert", + "append" + ], "description": "The commands to run. Allowed options are: `create`, `str_replace`, `insert`, `append`." }, "file_text": { @@ -117,7 +143,10 @@ "type": "string" } }, - "required": ["command", "path"] + "required": [ + "command", + "path" + ] } }, "use_aws": { @@ -151,7 +180,12 @@ "description": "Human readable description of the api that is being called." } }, - "required": ["region", "service_name", "operation_name", "label"] + "required": [ + "region", + "service_name", + "operation_name", + "label" + ] } }, "gh_issue": { @@ -177,7 +211,9 @@ "description": "Optional: Previous user chat requests or steps that were taken that may have resulted in the issue or error response." } }, - "required": ["title"] + "required": [ + "title" + ] } }, "thinking": { @@ -191,46 +227,59 @@ "description": "A reflective note or intermediate reasoning step such as \"The user needs to prepare their application for production. I need to complete three major asks including 1: building their code from source, 2: bundling their release artifacts together, and 3: signing the application bundle." } }, - "required": ["thought"] + "required": [ + "thought" + ] } }, "knowledge": { - "name": "knowledge", - "description": "Store and retrieve information in knowledge base across chat sessions. Provides semantic search capabilities for files, directories, and text content.", - "input_schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "enum": ["show", "add", "remove", "clear", "search", "update", "status", "cancel"], - "description": "The knowledge operation to perform:\n- 'show': List all knowledge contexts (no additional parameters required)\n- 'add': Add content to knowledge base (requires 'name' and 'value')\n- 'remove': Remove content from knowledge base (requires one of: 'name', 'context_id', or 'path')\n- 'clear': Remove all knowledge contexts.\n- 'search': Search across knowledge contexts (requires 'query', optional 'context_id')\n- 'update': Update existing context with new content (requires 'path' and one of: 'name', 'context_id')\n- 'status': Show background operation status and progress\n- 'cancel': Cancel background operations (optional 'operation_id' to cancel specific operation, or cancel all if not provided)" - }, - "name": { - "type": "string", - "description": "A descriptive name for the knowledge context. Required for 'add' operations. Can be used for 'remove' and 'update' operations to identify the context." - }, - "value": { - "type": "string", - "description": "The content to store in knowledge base. Required for 'add' operations. Can be either text content or a file/directory path. If it's a valid file or directory path, the content will be indexed; otherwise it's treated as text." - }, - "context_id": { - "type": "string", - "description": "The unique context identifier for targeted operations. Can be obtained from 'show' command. Used for 'remove', 'update', and 'search' operations to specify which context to operate on." - }, - "path": { - "type": "string", - "description": "File or directory path. Used in 'remove' operations to remove contexts by their source path, and required for 'update' operations to specify the new content location." - }, - "query": { - "type": "string", - "description": "The search query string. Required for 'search' operations. Performs semantic search across knowledge contexts to find relevant content." - }, - "operation_id": { - "type": "string", - "description": "Optional operation ID to cancel a specific operation. Used with 'cancel' command. If not provided, all active operations will be cancelled. Can be either the full operation ID or the short 8-character ID." - } + "name": "knowledge", + "description": "Store and retrieve information in knowledge base across chat sessions. Provides semantic search capabilities for files, directories, and text content.", + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "show", + "add", + "remove", + "clear", + "search", + "update", + "status", + "cancel" + ], + "description": "The knowledge operation to perform:\n- 'show': List all knowledge contexts (no additional parameters required)\n- 'add': Add content to knowledge base (requires 'name' and 'value')\n- 'remove': Remove content from knowledge base (requires one of: 'name', 'context_id', or 'path')\n- 'clear': Remove all knowledge contexts.\n- 'search': Search across knowledge contexts (requires 'query', optional 'context_id')\n- 'update': Update existing context with new content (requires 'path' and one of: 'name', 'context_id')\n- 'status': Show background operation status and progress\n- 'cancel': Cancel background operations (optional 'operation_id' to cancel specific operation, or cancel all if not provided)" }, - "required": ["command"] - } + "name": { + "type": "string", + "description": "A descriptive name for the knowledge context. Required for 'add' operations. Can be used for 'remove' and 'update' operations to identify the context." + }, + "value": { + "type": "string", + "description": "The content to store in knowledge base. Required for 'add' operations. Can be either text content or a file/directory path. If it's a valid file or directory path, the content will be indexed; otherwise it's treated as text." + }, + "context_id": { + "type": "string", + "description": "The unique context identifier for targeted operations. Can be obtained from 'show' command. Used for 'remove', 'update', and 'search' operations to specify which context to operate on." + }, + "path": { + "type": "string", + "description": "File or directory path. Used in 'remove' operations to remove contexts by their source path, and required for 'update' operations to specify the new content location." + }, + "query": { + "type": "string", + "description": "The search query string. Required for 'search' operations. Performs semantic search across knowledge contexts to find relevant content." + }, + "operation_id": { + "type": "string", + "description": "Optional operation ID to cancel a specific operation. Used with 'cancel' command. If not provided, all active operations will be cancelled. Can be either the full operation ID or the short 8-character ID." + } + }, + "required": [ + "command" + ] + } } -} +} \ No newline at end of file