feat(acp): add read tool and delegate filesystem I/O to ACP clients (#7668)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
@@ -507,7 +507,6 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_without_provider(data_dir: std::path::PathBuf) -> Self {
|
||||
let session_manager = Arc::new(crate::session::SessionManager::new(data_dir));
|
||||
Self::new(
|
||||
|
||||
@@ -7,6 +7,17 @@ use serde::Deserialize;
|
||||
|
||||
const NO_MATCH_PREVIEW_LINES: usize = 20;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct FileReadParams {
|
||||
/// Absolute path to the file to read.
|
||||
pub path: String,
|
||||
/// Line number to start reading from (1-based).
|
||||
#[schemars(range(min = 1))]
|
||||
pub line: Option<u32>,
|
||||
/// Maximum number of lines to read.
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct FileWriteParams {
|
||||
pub path: String,
|
||||
@@ -27,6 +38,26 @@ impl EditTools {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn file_read_with_cwd(
|
||||
&self,
|
||||
params: FileReadParams,
|
||||
working_dir: Option<&Path>,
|
||||
) -> CallToolResult {
|
||||
let path = resolve_path(¶ms.path, working_dir);
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let content = apply_line_limit(&content, params.line, params.limit);
|
||||
CallToolResult::success(vec![Content::text(content).with_priority(0.0)])
|
||||
}
|
||||
Err(error) => CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to read {}: {}",
|
||||
params.path, error
|
||||
))
|
||||
.with_priority(0.0)]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_write(&self, params: FileWriteParams) -> CallToolResult {
|
||||
self.file_write_with_cwd(params, None)
|
||||
}
|
||||
@@ -93,62 +124,27 @@ impl EditTools {
|
||||
}
|
||||
};
|
||||
|
||||
let matches: Vec<_> = content.match_indices(¶ms.before).collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => {
|
||||
let suggestion = find_similar_context(&content, ¶ms.before);
|
||||
let mut msg = "No match found for the specified text.".to_string();
|
||||
if let Some(hint) = suggestion {
|
||||
msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint));
|
||||
}
|
||||
let preview = build_file_preview(&content, NO_MATCH_PREVIEW_LINES);
|
||||
msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview));
|
||||
CallToolResult::error(vec![Content::text(msg).with_priority(0.0)])
|
||||
let new_content = match string_replace(&content, ¶ms.before, ¶ms.after) {
|
||||
Ok(c) => c,
|
||||
Err(msg) => {
|
||||
return CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]);
|
||||
}
|
||||
1 => {
|
||||
let new_content = content.replacen(¶ms.before, ¶ms.after, 1);
|
||||
|
||||
match fs::write(&path, &new_content) {
|
||||
Ok(()) => {
|
||||
let old_lines = params.before.lines().count();
|
||||
let new_lines = params.after.lines().count();
|
||||
CallToolResult::success(vec![Content::text(format!(
|
||||
"Edited {} ({} lines -> {} lines)",
|
||||
params.path, old_lines, new_lines
|
||||
))
|
||||
.with_priority(0.0)])
|
||||
}
|
||||
Err(error) => CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to write {}: {}",
|
||||
params.path, error
|
||||
))
|
||||
.with_priority(0.0)]),
|
||||
}
|
||||
}
|
||||
n => {
|
||||
let mut msg = format!(
|
||||
"Found {} matches. Please provide more context to identify a unique match:\n",
|
||||
n
|
||||
);
|
||||
|
||||
for (i, (pos, _)) in matches.iter().enumerate().take(2) {
|
||||
let line_num = count_lines_before(&content, *pos);
|
||||
let context = get_line_context(&content, line_num, 1);
|
||||
msg.push_str(&format!(
|
||||
"\nMatch {} (line {}):\n```\n{}\n```",
|
||||
i + 1,
|
||||
line_num,
|
||||
context
|
||||
));
|
||||
}
|
||||
|
||||
if n > 2 {
|
||||
msg.push_str(&format!("\n\n...and {} more", n - 2));
|
||||
}
|
||||
|
||||
CallToolResult::error(vec![Content::text(msg).with_priority(0.0)])
|
||||
};
|
||||
match fs::write(&path, &new_content) {
|
||||
Ok(()) => {
|
||||
let old_lines = params.before.lines().count();
|
||||
let new_lines = params.after.lines().count();
|
||||
CallToolResult::success(vec![Content::text(format!(
|
||||
"Edited {} ({} lines -> {} lines)",
|
||||
params.path, old_lines, new_lines
|
||||
))
|
||||
.with_priority(0.0)])
|
||||
}
|
||||
Err(error) => CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to write {}: {}",
|
||||
params.path, error
|
||||
))
|
||||
.with_priority(0.0)]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +155,64 @@ impl Default for EditTools {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
|
||||
pub fn string_replace(content: &str, before: &str, after: &str) -> Result<String, String> {
|
||||
let matches: Vec<_> = content.match_indices(before).collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => {
|
||||
let suggestion = find_similar_context(content, before);
|
||||
let mut msg = "No match found for the specified text.".to_string();
|
||||
if let Some(hint) = suggestion {
|
||||
msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint));
|
||||
}
|
||||
let preview = build_file_preview(content, NO_MATCH_PREVIEW_LINES);
|
||||
msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview));
|
||||
Err(msg)
|
||||
}
|
||||
1 => Ok(content.replacen(before, after, 1)),
|
||||
n => {
|
||||
let mut msg = format!(
|
||||
"Found {} matches. Please provide more context to identify a unique match:\n",
|
||||
n
|
||||
);
|
||||
|
||||
for (i, (pos, _)) in matches.iter().enumerate().take(2) {
|
||||
let line_num = count_lines_before(content, *pos);
|
||||
let context = get_line_context(content, line_num, 1);
|
||||
msg.push_str(&format!(
|
||||
"\nMatch {} (line {}):\n```\n{}\n```",
|
||||
i + 1,
|
||||
line_num,
|
||||
context
|
||||
));
|
||||
}
|
||||
|
||||
if n > 2 {
|
||||
msg.push_str(&format!("\n\n...and {} more", n - 2));
|
||||
}
|
||||
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_line_limit(content: &str, line: Option<u32>, limit: Option<u32>) -> String {
|
||||
if line.is_none() && limit.is_none() {
|
||||
return content.to_string();
|
||||
}
|
||||
let lines: Vec<&str> = content.split_inclusive('\n').collect();
|
||||
let start = line
|
||||
.map(|l| (l as usize).saturating_sub(1))
|
||||
.unwrap_or(0)
|
||||
.min(lines.len());
|
||||
let end = limit
|
||||
.map(|l| start + l as usize)
|
||||
.unwrap_or(lines.len())
|
||||
.min(lines.len());
|
||||
lines[start..end].concat()
|
||||
}
|
||||
|
||||
pub fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
@@ -231,6 +284,7 @@ mod tests {
|
||||
use rmcp::model::RawContent;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
|
||||
fn setup() -> TempDir {
|
||||
tempfile::tempdir().unwrap()
|
||||
@@ -243,6 +297,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test_case(None, None, "line1\nline2\nline3" ; "full content")]
|
||||
#[test_case(Some(2), None, "line2\nline3" ; "from line 2")]
|
||||
#[test_case(None, Some(2), "line1\nline2\n" ; "limit 2")]
|
||||
#[test_case(Some(2), Some(1), "line2\n" ; "line 2 limit 1")]
|
||||
#[test_case(Some(99), None, "" ; "beyond eof")]
|
||||
fn test_apply_line_limit(line: Option<u32>, limit: Option<u32>, expected: &str) {
|
||||
assert_eq!(
|
||||
apply_line_limit("line1\nline2\nline3", line, limit),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_read() {
|
||||
let dir = setup();
|
||||
let path = dir.path().join("read.txt");
|
||||
fs::write(&path, "line1\nline2\nline3").unwrap();
|
||||
let tools = EditTools::new();
|
||||
|
||||
let result = tools.file_read_with_cwd(
|
||||
FileReadParams {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
line: None,
|
||||
limit: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false));
|
||||
assert_eq!(extract_text(&result), "line1\nline2\nline3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_read_partial() {
|
||||
let dir = setup();
|
||||
let path = dir.path().join("read.txt");
|
||||
fs::write(&path, "line1\nline2\nline3").unwrap();
|
||||
let tools = EditTools::new();
|
||||
|
||||
let result = tools.file_read_with_cwd(
|
||||
FileReadParams {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
line: Some(2),
|
||||
limit: Some(1),
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false));
|
||||
assert_eq!(extract_text(&result), "line2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_write_new() {
|
||||
let dir = setup();
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::agents::extension::PlatformExtensionContext;
|
||||
use crate::agents::mcp_client::{Error, McpClientTrait};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use edit::{EditTools, FileEditParams, FileWriteParams};
|
||||
use edit::{EditTools, FileEditParams, FileReadParams, FileWriteParams};
|
||||
use indoc::indoc;
|
||||
use rmcp::model::{
|
||||
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
|
||||
@@ -65,7 +65,7 @@ impl DeveloperClient {
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn parse_args<T: serde::de::DeserializeOwned>(
|
||||
pub fn parse_args<T: serde::de::DeserializeOwned>(
|
||||
arguments: Option<JsonObject>,
|
||||
) -> Result<T, String> {
|
||||
let value = arguments
|
||||
@@ -74,7 +74,7 @@ impl DeveloperClient {
|
||||
serde_json::from_value(value).map_err(|e| format!("Failed to parse arguments: {e}"))
|
||||
}
|
||||
|
||||
fn get_tools() -> Vec<Tool> {
|
||||
pub(crate) fn get_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"write".to_string(),
|
||||
@@ -154,6 +154,13 @@ impl McpClientTrait for DeveloperClient {
|
||||
) -> Result<CallToolResult, Error> {
|
||||
let working_dir = working_dir.map(Path::new);
|
||||
match name {
|
||||
"read" => match Self::parse_args::<FileReadParams>(arguments) {
|
||||
Ok(params) => Ok(self.edit_tools.file_read_with_cwd(params, working_dir)),
|
||||
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"Error: {error}"
|
||||
))
|
||||
.with_priority(0.0)])),
|
||||
},
|
||||
"shell" => match Self::parse_args::<ShellParams>(arguments) {
|
||||
Ok(params) => Ok(self.shell_tool.shell_with_cwd(params, working_dir).await),
|
||||
Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)),
|
||||
|
||||
Reference in New Issue
Block a user