feat: add optional view_range to text editor tool and text editor insert tool (#3093)

Co-authored-by: Alice Hau <ahau@squareup.com>
This commit is contained in:
Alice Hau
2025-06-27 14:45:41 -04:00
committed by GitHub
parent e40e84ec2e
commit 3ad908eacd
+904 -13
View File
@@ -188,12 +188,16 @@ impl DeveloperRouter {
- `view`: View the content of a file.
- `write`: Create or overwrite a file with the given content
- `edit_file`: Edit the file with the new content.
- `insert`: Insert text at a specific line location in the file.
- `undo_edit`: Undo the last edit made to a file.
To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with
existing files! This is a full overwrite, so you must include everything - not just sections you are modifying.
To use the edit_file command, you must specify both `old_str` and `new_str` - {}.
To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning)
and `new_str` (the text to insert).
"#, editor.get_str_replace_description()},
"edit_file",
)
@@ -205,6 +209,7 @@ impl DeveloperRouter {
- `view`: View the content of a file.
- `write`: Create or overwrite a file with the given content
- `str_replace`: Replace a string in a file with a new string.
- `insert`: Insert text at a specific line location in the file.
- `undo_edit`: Undo the last edit made to a file.
To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with
@@ -213,6 +218,9 @@ impl DeveloperRouter {
To use the str_replace command, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one
unique section of the original file, including any whitespace. Make sure to include enough context that the match is not
ambiguous. The entire original string will be replaced with `new_str`.
To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning)
and `new_str` (the text to insert).
"#}.to_string(), "str_replace")
};
@@ -229,8 +237,19 @@ impl DeveloperRouter {
},
"command": {
"type": "string",
"enum": ["view", "write", str_replace_command, "undo_edit"],
"description": format!("Allowed options are: `view`, `write`, `{}`, `undo_edit`.", str_replace_command)
"enum": ["view", "write", str_replace_command, "insert", "undo_edit"],
"description": format!("Allowed options are: `view`, `write`, `{}`, `insert`, `undo_edit`.", str_replace_command)
},
"view_range": {
"type": "array",
"items": {"type": "integer"},
"minItems": 2,
"maxItems": 2,
"description": "Optional array of two integers specifying the start and end line numbers to view. Line numbers are 1-indexed, and -1 for the end line means read to the end of the file. This parameter only applies when viewing files, not directories."
},
"insert_line": {
"type": "integer",
"description": "The line number after which to insert the text (0 for beginning of file). This parameter is required when using the insert command."
},
"old_str": {"type": "string"},
"new_str": {"type": "string"},
@@ -677,7 +696,21 @@ impl DeveloperRouter {
}
match command {
"view" => self.text_editor_view(&path).await,
"view" => {
let view_range = params
.get("view_range")
.and_then(|v| v.as_array())
.and_then(|arr| {
if arr.len() == 2 {
let start = arr[0].as_i64().unwrap_or(1) as usize;
let end = arr[1].as_i64().unwrap_or(-1);
Some((start, end))
} else {
None
}
});
self.text_editor_view(&path, view_range).await
}
"write" => {
let file_text = params
.get("file_text")
@@ -704,6 +737,22 @@ impl DeveloperRouter {
self.text_editor_replace(&path, old_str, new_str).await
}
"insert" => {
let insert_line = params
.get("insert_line")
.and_then(|v| v.as_i64())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'insert_line' parameter".into())
})? as usize;
let new_str = params
.get("new_str")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'new_str' parameter".into())
})?;
self.text_editor_insert(&path, insert_line, new_str).await
}
"undo_edit" => self.text_editor_undo(&path).await,
_ => Err(ToolError::InvalidParameters(format!(
"Unknown command '{}'",
@@ -712,7 +761,11 @@ impl DeveloperRouter {
}
}
async fn text_editor_view(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
async fn text_editor_view(
&self,
path: &PathBuf,
view_range: Option<(usize, i64)>,
) -> Result<Vec<Content>, ToolError> {
if path.is_file() {
// Check file size first (400KB limit)
const MAX_FILE_SIZE: u64 = 400 * 1024; // 400KB in bytes
@@ -749,16 +802,76 @@ impl DeveloperRouter {
)));
}
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// Handle view_range if provided, otherwise show all lines
let (start_idx, end_idx) = if let Some((start_line, end_line)) = view_range {
// Convert 1-indexed line numbers to 0-indexed
let start_idx = if start_line > 0 { start_line - 1 } else { 0 };
let end_idx = if end_line == -1 {
total_lines
} else {
std::cmp::min(end_line as usize, total_lines)
};
if start_idx >= total_lines {
return Err(ToolError::InvalidParameters(format!(
"Start line {} is beyond the end of the file (total lines: {})",
start_line, total_lines
)));
}
if start_idx >= end_idx {
return Err(ToolError::InvalidParameters(format!(
"Start line {} must be less than end line {}",
start_line, end_line
)));
}
(start_idx, end_idx)
} else {
(0, total_lines)
};
// Always format lines with line numbers for better usability
let display_content = if total_lines == 0 {
String::new()
} else {
let selected_lines: Vec<String> = lines[start_idx..end_idx]
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start_idx + i + 1, line))
.collect();
selected_lines.join("\n")
};
let language = lang::get_language_identifier(path);
let formatted = formatdoc! {"
### {path}
```{language}
{content}
```
",
path=path.display(),
language=language,
content=content,
let formatted = if view_range.is_some() {
formatdoc! {"
### {path} (lines {start}-{end})
```{language}
{content}
```
",
path=path.display(),
start=view_range.unwrap().0,
end=if view_range.unwrap().1 == -1 { "end".to_string() } else { view_range.unwrap().1.to_string() },
language=language,
content=display_content,
}
} else {
formatdoc! {"
### {path}
```{language}
{content}
```
",
path=path.display(),
language=language,
content=display_content,
}
};
// The LLM gets just a quick update as we expect the file to view in the status
@@ -943,6 +1056,116 @@ impl DeveloperRouter {
.with_priority(0.2),
])
}
async fn text_editor_insert(
&self,
path: &PathBuf,
insert_line: usize,
new_str: &str,
) -> Result<Vec<Content>, ToolError> {
// Check if file exists
if !path.exists() {
return Err(ToolError::InvalidParameters(format!(
"File '{}' does not exist, you can write a new file with the `write` command",
path.display()
)));
}
// Read content
let content = std::fs::read_to_string(path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?;
// Save history for undo
self.save_file_history(path)?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// Validate insert_line parameter
if insert_line > total_lines {
return Err(ToolError::InvalidParameters(format!(
"Insert line {} is beyond the end of the file (total lines: {}). Use 0 to insert at the beginning or {} to insert at the end.",
insert_line, total_lines, total_lines
)));
}
// Create new content with inserted text
let mut new_lines = Vec::new();
// Add lines before the insertion point
for (i, line) in lines.iter().enumerate() {
if i == insert_line {
// Insert the new text at this position
new_lines.push(new_str.to_string());
}
new_lines.push(line.to_string());
}
// If inserting at the end (after all existing lines)
if insert_line == total_lines {
new_lines.push(new_str.to_string());
}
let new_content = new_lines.join("\n");
let normalized_content = normalize_line_endings(&new_content);
// Ensure the file ends with a newline
let final_content = if !normalized_content.ends_with('\n') {
format!("{}\n", normalized_content)
} else {
normalized_content
};
std::fs::write(path, &final_content)
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// Show a snippet of the inserted content with context
const SNIPPET_LINES: usize = 4;
let insertion_line = insert_line + 1; // Convert to 1-indexed for display
// Calculate start and end lines for the snippet
let start_line = insertion_line.saturating_sub(SNIPPET_LINES);
let end_line = std::cmp::min(insertion_line + SNIPPET_LINES, new_lines.len());
// Get the relevant lines for our snippet with line numbers
let snippet_lines: Vec<String> = new_lines[start_line.saturating_sub(1)..end_line]
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start_line + i, line))
.collect();
let snippet = snippet_lines.join("\n");
let output = formatdoc! {r#"
```{language}
{snippet}
```
"#,
language=language,
snippet=snippet
};
let success_message = formatdoc! {r#"
Text has been inserted at line {} in {}. The section now reads:
{}
Review the changes above for errors. Undo and edit the file again if necessary!
"#,
insertion_line,
path.display(),
output
};
Ok(vec![
Content::text(success_message).with_audience(vec![Role::Assistant]),
Content::text(output)
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
async fn text_editor_undo(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
let mut history = self.file_history.lock().unwrap();
if let Some(contents) = history.get_mut(path) {
@@ -2083,4 +2306,672 @@ mod tests {
temp_dir.close().unwrap();
}
// Tests for view_range functionality
#[tokio::test]
#[serial]
async fn test_text_editor_view_range() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a multi-line file
let content =
"Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Test viewing specific range
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str,
"view_range": [3, 6]
}),
dummy_sender(),
)
.await
.unwrap();
let text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
// Should contain lines 3-6 with line numbers
assert!(text.contains("3: Line 3"));
assert!(text.contains("4: Line 4"));
assert!(text.contains("5: Line 5"));
assert!(text.contains("6: Line 6"));
assert!(text.contains("(lines 3-6)"));
// Should not contain other lines
assert!(!text.contains("1: Line 1"));
assert!(!text.contains("7: Line 7"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_view_range_to_end() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a multi-line file
let content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Test viewing from line 3 to end using -1
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str,
"view_range": [3, -1]
}),
dummy_sender(),
)
.await
.unwrap();
let text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
// Should contain lines 3 to end
assert!(text.contains("3: Line 3"));
assert!(text.contains("4: Line 4"));
assert!(text.contains("5: Line 5"));
assert!(text.contains("(lines 3-end)"));
// Should not contain earlier lines
assert!(!text.contains("1: Line 1"));
assert!(!text.contains("2: Line 2"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_view_range_invalid() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a small file
let content = "Line 1\nLine 2\nLine 3";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Test invalid range - start beyond end of file
let result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str,
"view_range": [10, 15]
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("beyond the end of the file"));
// Test invalid range - start >= end
let result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str,
"view_range": [3, 2]
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("must be less than end line"));
temp_dir.close().unwrap();
}
// Tests for insert functionality
#[tokio::test]
#[serial]
async fn test_text_editor_insert_at_beginning() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file with some content
let content = "Line 2\nLine 3\nLine 4";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Insert at the beginning (line 0)
let insert_result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 0,
"new_str": "Line 1"
}),
dummy_sender(),
)
.await
.unwrap();
let text = insert_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::Assistant))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("Text has been inserted at line 1"));
// Verify the file content
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
dummy_sender(),
)
.await
.unwrap();
let view_text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(view_text.contains("1: Line 1"));
assert!(view_text.contains("2: Line 2"));
assert!(view_text.contains("3: Line 3"));
assert!(view_text.contains("4: Line 4"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_in_middle() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file with some content
let content = "Line 1\nLine 2\nLine 4\nLine 5";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Insert after line 2
let insert_result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 2,
"new_str": "Line 3"
}),
dummy_sender(),
)
.await
.unwrap();
let text = insert_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::Assistant))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("Text has been inserted at line 3"));
// Verify the file content
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
dummy_sender(),
)
.await
.unwrap();
let view_text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(view_text.contains("1: Line 1"));
assert!(view_text.contains("2: Line 2"));
assert!(view_text.contains("3: Line 3"));
assert!(view_text.contains("4: Line 4"));
assert!(view_text.contains("5: Line 5"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_at_end() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file with some content
let content = "Line 1\nLine 2\nLine 3";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Insert at the end (after line 3)
let insert_result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 3,
"new_str": "Line 4"
}),
dummy_sender(),
)
.await
.unwrap();
let text = insert_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::Assistant))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("Text has been inserted at line 4"));
// Verify the file content
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
dummy_sender(),
)
.await
.unwrap();
let view_text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(view_text.contains("1: Line 1"));
assert!(view_text.contains("2: Line 2"));
assert!(view_text.contains("3: Line 3"));
assert!(view_text.contains("4: Line 4"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_invalid_line() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file with some content
let content = "Line 1\nLine 2\nLine 3";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Try to insert beyond the end of the file
let result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 10,
"new_str": "Line 11"
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("beyond the end of the file"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_missing_parameters() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": "Test content"
}),
dummy_sender(),
)
.await
.unwrap();
// Try insert without insert_line parameter
let result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"new_str": "New line"
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("Missing 'insert_line' parameter"));
// Try insert without new_str parameter
let result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 1
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("Missing 'new_str' parameter"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_with_undo() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a file with some content
let content = "Line 1\nLine 2";
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": content
}),
dummy_sender(),
)
.await
.unwrap();
// Insert a line
router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 1,
"new_str": "Inserted Line"
}),
dummy_sender(),
)
.await
.unwrap();
// Undo the insert
let undo_result = router
.call_tool(
"text_editor",
json!({
"command": "undo_edit",
"path": file_path_str
}),
dummy_sender(),
)
.await
.unwrap();
let text = undo_result.first().unwrap().as_text().unwrap();
assert!(text.contains("Undid the last edit"));
// Verify the file is back to original content
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
dummy_sender(),
)
.await
.unwrap();
let view_text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(view_text.contains("1: Line 1"));
assert!(view_text.contains("2: Line 2"));
assert!(!view_text.contains("Inserted Line"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_insert_nonexistent_file() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("nonexistent.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Try to insert into a nonexistent file
let result = router
.call_tool(
"text_editor",
json!({
"command": "insert",
"path": file_path_str,
"insert_line": 0,
"new_str": "New line"
}),
dummy_sender(),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
assert!(err.to_string().contains("does not exist"));
temp_dir.close().unwrap();
}
}