feat(tools): add cross-platform read/write/edit/shell tools
- read: line-range reads, binary detection - write: overwrite with parent-dir creation - edit: pi-style exact oldString/newString replacement with occurrence - shell: bash on Linux, PowerShell (cmd fallback) on Windows; streaming line updates via ToolUpdateSink, kill-on-timeout - sync std I/O consistent with the core Tool trait; no tokio needed
This commit is contained in:
parent
8ed56c3952
commit
ee165235ec
|
|
@ -4,8 +4,8 @@ version.workspace = true
|
|||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Built-in tools (read, write, edit, bash) for the focus agent"
|
||||
description = "Built-in tools (read, write, edit, shell) for the focus agent"
|
||||
|
||||
[dependencies]
|
||||
focus-core.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "process", "fs", "io-util", "sync"] }
|
||||
focus-json.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
//! `edit` 工具:pi 风格的精确字符串替换。
|
||||
//! The `edit` tool: pi-style exact string replacement.
|
||||
//!
|
||||
//! 一次调用可含多组 `{oldString, newString}`,按顺序应用。每组默认要求
|
||||
//! `oldString` 唯一匹配;出现多次时必须显式给出 1 起始的 `occurrence`。
|
||||
//! A single call may carry multiple `{oldString, newString}` pairs, applied in
|
||||
//! order. Each pair requires a unique match by default; when a string appears
|
||||
//! multiple times, a 1-based `occurrence` must be given explicitly.
|
||||
|
||||
use crate::resolve_path;
|
||||
use focus_core::model::ContentBlock;
|
||||
use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink};
|
||||
use focus_core::{CoreError, CoreResult};
|
||||
use focus_json::JsonValue;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 应用精确替换的编辑工具。
|
||||
/// A tool that applies exact replacements.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditTool {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl EditTool {
|
||||
/// 以项目根目录构造工具。
|
||||
/// Build the tool with a project root.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for EditTool {
|
||||
fn name(&self) -> &str {
|
||||
"edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Apply one or more exact string replacements to a file. Each edit must \
|
||||
match uniquely unless an 'occurrence' (1-based) is given. Edits are \
|
||||
applied in order."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> JsonValue {
|
||||
let mut o = JsonValue::obj();
|
||||
o.insert("type", "object".into()).ok();
|
||||
let mut props = JsonValue::obj();
|
||||
let mut path = JsonValue::obj();
|
||||
path.insert("type", "string".into()).ok();
|
||||
props.insert("path", path).ok();
|
||||
let mut edits = JsonValue::obj();
|
||||
edits.insert("type", "array".into()).ok();
|
||||
let mut item = JsonValue::obj();
|
||||
item.insert("type", "object".into()).ok();
|
||||
let mut item_props = JsonValue::obj();
|
||||
let mut old = JsonValue::obj();
|
||||
old.insert("type", "string".into()).ok();
|
||||
item_props.insert("oldString", old).ok();
|
||||
let mut new = JsonValue::obj();
|
||||
new.insert("type", "string".into()).ok();
|
||||
item_props.insert("newString", new).ok();
|
||||
let mut occ = JsonValue::obj();
|
||||
occ.insert("type", "number".into()).ok();
|
||||
item_props.insert("occurrence", occ).ok();
|
||||
item.insert("properties", item_props).ok();
|
||||
let mut item_required = JsonValue::arr();
|
||||
item_required.push("oldString".into()).ok();
|
||||
item_required.push("newString".into()).ok();
|
||||
item.insert("required", item_required).ok();
|
||||
edits.insert("items", item).ok();
|
||||
props.insert("edits", edits).ok();
|
||||
o.insert("properties", props).ok();
|
||||
let mut required = JsonValue::arr();
|
||||
required.push("path".into()).ok();
|
||||
required.push("edits".into()).ok();
|
||||
o.insert("required", required).ok();
|
||||
o
|
||||
}
|
||||
|
||||
fn effects(&self) -> ToolEffects {
|
||||
ToolEffects::WRITE
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_id: &str,
|
||||
args: &JsonValue,
|
||||
_on_update: Option<&ToolUpdateSink>,
|
||||
) -> CoreResult<ToolResult> {
|
||||
let path = args
|
||||
.get_str("path")
|
||||
.ok_or_else(|| CoreError::InvalidArgs("edit: missing 'path'".into()))?;
|
||||
let edits = args
|
||||
.get_arr("edits")
|
||||
.ok_or_else(|| CoreError::InvalidArgs("edit: missing 'edits' array".into()))?;
|
||||
if edits.is_empty() {
|
||||
return Err(CoreError::InvalidArgs("edit: 'edits' is empty".into()));
|
||||
}
|
||||
let resolved = resolve_path(&self.root, path);
|
||||
let content = std::fs::read_to_string(&resolved)
|
||||
.map_err(|e| CoreError::Tool(format!("edit {}: {}", resolved.display(), e)))?;
|
||||
|
||||
let mut current = content;
|
||||
let mut applied = 0usize;
|
||||
for (i, edit) in edits.iter().enumerate() {
|
||||
let old_string = edit.get_str("oldString").ok_or_else(|| {
|
||||
CoreError::InvalidArgs(format!("edit #{}: missing 'oldString'", i + 1))
|
||||
})?;
|
||||
let new_string = edit.get_str("newString").ok_or_else(|| {
|
||||
CoreError::InvalidArgs(format!("edit #{}: missing 'newString'", i + 1))
|
||||
})?;
|
||||
let occurrence = edit.get_num("occurrence").map(|n| n as usize);
|
||||
|
||||
// 收集所有匹配位置。
|
||||
// Collect all match positions.
|
||||
let positions: Vec<usize> = current
|
||||
.match_indices(old_string)
|
||||
.map(|(pos, _)| pos)
|
||||
.collect();
|
||||
|
||||
let chosen = match occurrence {
|
||||
Some(occ) if occ >= 1 && occ <= positions.len() => positions[occ - 1],
|
||||
Some(occ) => {
|
||||
return Err(CoreError::Tool(format!(
|
||||
"edit {}: occurrence {} out of range ({} matches found)",
|
||||
resolved.display(),
|
||||
occ,
|
||||
positions.len()
|
||||
)))
|
||||
}
|
||||
None if positions.len() == 1 => positions[0],
|
||||
None if positions.is_empty() => {
|
||||
return Err(CoreError::Tool(format!(
|
||||
"edit {}: oldString #{} not found: {:?}",
|
||||
resolved.display(),
|
||||
i + 1,
|
||||
old_string
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Err(CoreError::Tool(format!(
|
||||
"edit {}: oldString #{} matches {} times; specify 'occurrence'",
|
||||
resolved.display(),
|
||||
i + 1,
|
||||
positions.len()
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// 拼接替换后的内容。
|
||||
// Splice in the replacement.
|
||||
let mut next = String::with_capacity(current.len() + new_string.len());
|
||||
next.push_str(¤t[..chosen]);
|
||||
next.push_str(new_string);
|
||||
next.push_str(¤t[chosen + old_string.len()..]);
|
||||
current = next;
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
std::fs::write(&resolved, current.as_bytes())
|
||||
.map_err(|e| CoreError::Tool(format!("edit {}: {}", resolved.display(), e)))?;
|
||||
|
||||
let mut details = JsonValue::obj();
|
||||
details
|
||||
.insert("path", resolved.display().to_string().into())
|
||||
.ok();
|
||||
details.insert("applied", (applied as f64).into()).ok();
|
||||
Ok(ToolResult {
|
||||
content: vec![ContentBlock::text(format!(
|
||||
"Applied {} edit(s) to {}",
|
||||
applied,
|
||||
resolved.display()
|
||||
))],
|
||||
details,
|
||||
added_tool_names: Vec::new(),
|
||||
terminate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn edit_args(path: &str, edits: Vec<JsonValue>) -> JsonValue {
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", path.into()).ok();
|
||||
args.insert("edits", JsonValue::Arr(edits)).ok();
|
||||
args
|
||||
}
|
||||
|
||||
fn pair(old: &str, new: &str) -> JsonValue {
|
||||
let mut e = JsonValue::obj();
|
||||
e.insert("oldString", old.into()).ok();
|
||||
e.insert("newString", new.into()).ok();
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_single_replacement() {
|
||||
let dir = temp_dir("single");
|
||||
fs::write(dir.join("f.txt"), "foo bar foo").unwrap();
|
||||
let tool = EditTool::new(&dir);
|
||||
let r = tool
|
||||
.execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None)
|
||||
.unwrap();
|
||||
assert!(r.content[0].as_text().unwrap().text.contains("1 edit"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("f.txt")).unwrap(),
|
||||
"foo baz foo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_multiple_edits_in_order() {
|
||||
let dir = temp_dir("multi");
|
||||
fs::write(dir.join("f.txt"), "a b c").unwrap();
|
||||
let tool = EditTool::new(&dir);
|
||||
let r = tool
|
||||
.execute(
|
||||
"id",
|
||||
&edit_args(
|
||||
"f.txt",
|
||||
vec![pair("a", "1"), pair("b", "2"), pair("c", "3")],
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.content[0].as_text().unwrap().text.contains("3 edit"));
|
||||
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_match_requires_occurrence() {
|
||||
let dir = temp_dir("ambig");
|
||||
fs::write(dir.join("f.txt"), "x x x").unwrap();
|
||||
let tool = EditTool::new(&dir);
|
||||
// 无 occurrence → 报错。
|
||||
// No occurrence → error.
|
||||
let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None);
|
||||
assert!(err.is_err());
|
||||
let msg = err.unwrap_err().to_string();
|
||||
assert!(msg.contains("3 times"), "got: {}", msg);
|
||||
// 带 occurrence → 只替换第 2 个。
|
||||
// With occurrence → replace only the 2nd.
|
||||
let mut e = pair("x", "y");
|
||||
e.insert("occurrence", 2u64.into()).ok();
|
||||
tool.execute("id", &edit_args("f.txt", vec![e]), None)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_reports_descriptively() {
|
||||
let dir = temp_dir("nf");
|
||||
fs::write(dir.join("f.txt"), "hello").unwrap();
|
||||
let tool = EditTool::new(&dir);
|
||||
let err = tool
|
||||
.execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_crlf_when_not_matched() {
|
||||
let dir = temp_dir("crlf");
|
||||
fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap();
|
||||
let tool = EditTool::new(&dir);
|
||||
tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None)
|
||||
.unwrap();
|
||||
// 未匹配的 CRLF 原样保留。
|
||||
// Unmatched CRLF bytes are preserved as-is.
|
||||
assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,44 @@
|
|||
//! focus-tools:内置的文件与 shell 工具集。
|
||||
//! focus-tools: built-in file and shell tools.
|
||||
//!
|
||||
//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`bash`。
|
||||
//! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `bash`.
|
||||
//!
|
||||
//! 本里程碑阶段尚未实现。
|
||||
//! Not yet implemented in this milestone.
|
||||
//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`shell`。
|
||||
//! 全部使用同步 `std` I/O(与 core 的同步 `Tool` trait 一致),并原生支持
|
||||
//! Windows 与 Linux(路径用 `std::path`,shell 按平台选择)。
|
||||
//! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `shell`.
|
||||
//! All use synchronous `std` I/O (consistent with the core's sync `Tool`
|
||||
//! trait) and natively support both Windows and Linux (paths via `std::path`,
|
||||
//! the shell chosen per platform).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// pi 风格的精确替换编辑工具。
|
||||
/// The pi-style exact-replacement edit tool.
|
||||
pub mod edit;
|
||||
/// 读取文件(支持行范围)。
|
||||
/// File reading (with optional line ranges).
|
||||
pub mod read;
|
||||
/// 平台感知的 shell 执行工具。
|
||||
/// Platform-aware shell execution tool.
|
||||
pub mod shell;
|
||||
/// 整文件覆盖写入。
|
||||
/// Whole-file overwrite writing.
|
||||
pub mod write;
|
||||
|
||||
pub use edit::EditTool;
|
||||
pub use read::ReadTool;
|
||||
pub use shell::ShellTool;
|
||||
pub use write::WriteTool;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 把用户提供的路径解析为绝对路径:相对路径基于 `root`,绝对路径原样保留。
|
||||
/// Resolve a user-supplied path: relative paths are based on `root`,
|
||||
/// absolute paths are kept as-is.
|
||||
pub(crate) fn resolve_path(root: &Path, input: &str) -> PathBuf {
|
||||
let p = Path::new(input);
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
root.join(p)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
//! `read` 工具:读取文件,支持 1 起始的行范围。
|
||||
//! The `read` tool: read a file, with optional 1-based line ranges.
|
||||
|
||||
use crate::resolve_path;
|
||||
use focus_core::model::ContentBlock;
|
||||
use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink};
|
||||
use focus_core::CoreResult;
|
||||
use focus_json::JsonValue;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 读取文件的工具。
|
||||
/// A tool that reads files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadTool {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ReadTool {
|
||||
/// 以项目根目录构造工具(相对路径基于它解析)。
|
||||
/// Build the tool with a project root (relative paths resolve against it).
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read a file from disk. Provide the path and optionally a 1-based line \
|
||||
range (startLine/endLine) to read only part of it."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> JsonValue {
|
||||
let mut o = JsonValue::obj();
|
||||
o.insert("type", "object".into()).ok();
|
||||
let mut props = JsonValue::obj();
|
||||
let mut path = JsonValue::obj();
|
||||
path.insert("type", "string".into()).ok();
|
||||
path.insert(
|
||||
"description",
|
||||
"file path (absolute or relative to the project root)".into(),
|
||||
)
|
||||
.ok();
|
||||
props.insert("path", path).ok();
|
||||
let mut start = JsonValue::obj();
|
||||
start.insert("type", "number".into()).ok();
|
||||
props.insert("startLine", start).ok();
|
||||
let mut end = JsonValue::obj();
|
||||
end.insert("type", "number".into()).ok();
|
||||
props.insert("endLine", end).ok();
|
||||
o.insert("properties", props).ok();
|
||||
let mut required = JsonValue::arr();
|
||||
required.push("path".into()).ok();
|
||||
o.insert("required", required).ok();
|
||||
o
|
||||
}
|
||||
|
||||
fn effects(&self) -> ToolEffects {
|
||||
ToolEffects::READ
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_id: &str,
|
||||
args: &JsonValue,
|
||||
_on_update: Option<&ToolUpdateSink>,
|
||||
) -> CoreResult<ToolResult> {
|
||||
let path = args
|
||||
.get_str("path")
|
||||
.ok_or_else(|| focus_core::CoreError::InvalidArgs("read: missing 'path'".into()))?;
|
||||
let resolved = resolve_path(&self.root, path);
|
||||
|
||||
// 二进制文件检测:包含 NUL 字节即视为二进制。
|
||||
// Binary detection: NUL bytes mark a file as binary.
|
||||
let bytes = std::fs::read(&resolved).map_err(|e| {
|
||||
focus_core::CoreError::Tool(format!("read {}: {}", resolved.display(), e))
|
||||
})?;
|
||||
if bytes.contains(&0u8) {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"read {}: binary file (contains NUL bytes); refusing to read as text",
|
||||
resolved.display()
|
||||
)));
|
||||
}
|
||||
let content = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
||||
let start_line = args.get_num("startLine").map(|n| n as usize);
|
||||
let end_line = args.get_num("endLine").map(|n| n as usize);
|
||||
let text = match (start_line, end_line) {
|
||||
(None, None) => content,
|
||||
(start, end) => slice_lines(&content, start, end),
|
||||
};
|
||||
|
||||
let mut details = JsonValue::obj();
|
||||
details
|
||||
.insert("path", resolved.display().to_string().into())
|
||||
.ok();
|
||||
details.insert("bytes", (bytes.len() as f64).into()).ok();
|
||||
Ok(ToolResult {
|
||||
content: vec![ContentBlock::text(text)],
|
||||
details,
|
||||
added_tool_names: Vec::new(),
|
||||
terminate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 1 起始的行号切片;`end` 为 None 时读到文件末尾。
|
||||
/// Slice by 1-based line numbers; `None` end reads to the end of the file.
|
||||
fn slice_lines(content: &str, start: Option<usize>, end: Option<usize>) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_idx = start.unwrap_or(1).saturating_sub(1);
|
||||
let end_idx = match end {
|
||||
Some(e) if e >= 1 => e.min(lines.len()),
|
||||
Some(_) => 0,
|
||||
None => lines.len(),
|
||||
};
|
||||
if start_idx >= end_idx {
|
||||
return String::new();
|
||||
}
|
||||
lines[start_idx..end_idx].join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_file() {
|
||||
let dir = temp_dir("reads");
|
||||
fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap();
|
||||
let tool = ReadTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "a.txt".into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert_eq!(text, "line1\nline2\nline3\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_line_range() {
|
||||
let dir = temp_dir("range");
|
||||
fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap();
|
||||
let tool = ReadTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "a.txt".into()).ok();
|
||||
args.insert("startLine", 2u64.into()).ok();
|
||||
args.insert("endLine", 3u64.into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert_eq!(text, "l2\nl3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_an_error_result() {
|
||||
let dir = temp_dir("missing");
|
||||
let tool = ReadTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "nope.txt".into()).ok();
|
||||
// 可预期错误以 Err 返回(agent 循环会将其编码为错误结果)。
|
||||
// Expected errors surface as Err (the agent loop encodes them as
|
||||
// error results).
|
||||
let err = tool.execute("id", &args, None).unwrap_err().to_string();
|
||||
assert!(err.contains("nope.txt"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_file_is_rejected() {
|
||||
let dir = temp_dir("bin");
|
||||
fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap();
|
||||
let tool = ReadTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "b.bin".into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
assert!(r.content[0]
|
||||
.as_text()
|
||||
.map(|t| t.text.contains("binary"))
|
||||
.unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_works() {
|
||||
let dir = temp_dir("abs");
|
||||
let abs = dir.join("x.txt");
|
||||
fs::write(&abs, "hi").unwrap();
|
||||
let tool = ReadTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", abs.display().to_string().into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert_eq!(text, "hi");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
//! `shell` 工具:跨平台 shell 执行。
|
||||
//! The `shell` tool: cross-platform shell execution.
|
||||
//!
|
||||
//! Linux 默认使用 `bash -c`;Windows 默认使用 PowerShell(`powershell.exe
|
||||
//! -NoProfile -Command`),失败时回退到 `cmd.exe /C`。可用环境变量
|
||||
//! `FOCUS_SHELL` 覆盖(此时仍按平台选择参数形式)。输出按行通过
|
||||
//! [`ToolUpdateSink`] 增量推送;超时(默认 30s,可配 `timeoutMs`)时杀死子进程。
|
||||
//! Linux defaults to `bash -c`; Windows defaults to PowerShell
|
||||
//! (`powershell.exe -NoProfile -Command`), falling back to `cmd.exe /C`. The
|
||||
//! `FOCUS_SHELL` env var overrides the executable (arguments still follow the
|
||||
//! platform convention). Output is streamed line-by-line via
|
||||
//! [`ToolUpdateSink`]; on timeout (default 30s, configurable via `timeoutMs`)
|
||||
//! the child process is killed.
|
||||
|
||||
use crate::resolve_path;
|
||||
use focus_core::model::ContentBlock;
|
||||
use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdate, ToolUpdateSink};
|
||||
use focus_core::{CoreError, CoreResult};
|
||||
use focus_json::JsonValue;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{channel, RecvTimeoutError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 默认命令超时(秒)。
|
||||
/// Default command timeout (seconds).
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 执行平台 shell 命令的工具。
|
||||
/// A tool that runs platform shell commands.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellTool {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
/// 以项目根目录构造工具。
|
||||
/// Build the tool with a project root.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ShellTool {
|
||||
fn name(&self) -> &str {
|
||||
"shell"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Run a shell command and capture its output. Uses bash on Linux and \
|
||||
PowerShell on Windows (falling back to cmd.exe). Output streams as it \
|
||||
is produced; commands time out after 30s by default (set timeoutMs)."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> JsonValue {
|
||||
let mut o = JsonValue::obj();
|
||||
o.insert("type", "object".into()).ok();
|
||||
let mut props = JsonValue::obj();
|
||||
let mut command = JsonValue::obj();
|
||||
command.insert("type", "string".into()).ok();
|
||||
props.insert("command", command).ok();
|
||||
let mut cwd = JsonValue::obj();
|
||||
cwd.insert("type", "string".into()).ok();
|
||||
props.insert("cwd", cwd).ok();
|
||||
let mut timeout = JsonValue::obj();
|
||||
timeout.insert("type", "number".into()).ok();
|
||||
props.insert("timeoutMs", timeout).ok();
|
||||
o.insert("properties", props).ok();
|
||||
let mut required = JsonValue::arr();
|
||||
required.push("command".into()).ok();
|
||||
o.insert("required", required).ok();
|
||||
o
|
||||
}
|
||||
|
||||
fn effects(&self) -> ToolEffects {
|
||||
ToolEffects::PROCESS
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_id: &str,
|
||||
args: &JsonValue,
|
||||
on_update: Option<&ToolUpdateSink>,
|
||||
) -> CoreResult<ToolResult> {
|
||||
let command = args
|
||||
.get_str("command")
|
||||
.ok_or_else(|| CoreError::InvalidArgs("shell: missing 'command'".into()))?
|
||||
.to_string();
|
||||
let timeout_ms = args
|
||||
.get_num("timeoutMs")
|
||||
.map(|n| n as u64)
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS * 1000);
|
||||
let timeout = Duration::from_millis(timeout_ms.max(1));
|
||||
|
||||
let mut cmd = build_command(&command);
|
||||
let cwd = match args.get_str("cwd") {
|
||||
Some(c) => resolve_path(&self.root, c),
|
||||
None => self.root.clone(),
|
||||
};
|
||||
cmd.current_dir(&cwd);
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| CoreError::Tool(format!("shell spawn: {}", e)))?;
|
||||
|
||||
let result = run_child(&mut child, timeout, on_update)?;
|
||||
let (stdout, stderr, exit_code, timed_out) = result;
|
||||
|
||||
let mut details = JsonValue::obj();
|
||||
details.insert("exitCode", (exit_code as f64).into()).ok();
|
||||
details.insert("stdout", stdout.clone().into()).ok();
|
||||
details.insert("stderr", stderr.clone().into()).ok();
|
||||
details.insert("timedOut", timed_out.into()).ok();
|
||||
|
||||
let combined = if stdout.is_empty() {
|
||||
stderr
|
||||
} else if stderr.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{}\n{}", stdout, stderr)
|
||||
};
|
||||
|
||||
let text = if timed_out {
|
||||
format!(
|
||||
"Command timed out after {}ms.\n{}",
|
||||
timeout_ms,
|
||||
truncate(&combined, 2000)
|
||||
)
|
||||
} else {
|
||||
truncate(&combined, 2000)
|
||||
};
|
||||
|
||||
Ok(ToolResult {
|
||||
content: vec![ContentBlock::text(text)],
|
||||
details,
|
||||
added_tool_names: Vec::new(),
|
||||
terminate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造平台 shell 命令。
|
||||
/// Build the platform shell command.
|
||||
fn build_command(command: &str) -> Command {
|
||||
let mut cmd = Command::new(shell_program());
|
||||
if cfg!(windows) {
|
||||
// PowerShell 失败时调用方回退到 cmd.exe。
|
||||
// Fall back to cmd.exe when PowerShell fails to spawn.
|
||||
cmd.args(["-NoProfile", "-Command", command]);
|
||||
} else {
|
||||
cmd.args(["-c", command]);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// 选择 shell 程序:`FOCUS_SHELL` 优先,否则按平台默认。
|
||||
/// Pick the shell program: `FOCUS_SHELL` wins, else the platform default.
|
||||
fn shell_program() -> String {
|
||||
if let Ok(s) = std::env::var("FOCUS_SHELL") {
|
||||
if !s.is_empty() {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
if cfg!(windows) {
|
||||
"powershell.exe".to_string()
|
||||
} else {
|
||||
"bash".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// stdout/stderr 读取线程发来的消息。
|
||||
/// Messages sent by the stdout/stderr reader threads.
|
||||
enum StreamMsg {
|
||||
/// 一行输出;`true` 表示来自 stderr。
|
||||
/// One line of output; `true` means it came from stderr.
|
||||
Line(String, bool),
|
||||
/// 对应管道已到达 EOF。
|
||||
/// The corresponding pipe reached EOF.
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// 运行子进程并收集输出;返回 (stdout, stderr, exit_code, timed_out)。
|
||||
/// Run the child and collect its output; returns
|
||||
/// (stdout, stderr, exit_code, timed_out).
|
||||
fn run_child(
|
||||
child: &mut Child,
|
||||
timeout: Duration,
|
||||
on_update: Option<&ToolUpdateSink>,
|
||||
) -> CoreResult<(String, String, i32, bool)> {
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| CoreError::Tool("shell: no stdout pipe".into()))?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| CoreError::Tool("shell: no stderr pipe".into()))?;
|
||||
|
||||
let (tx, rx) = channel::<StreamMsg>();
|
||||
spawn_reader(stdout, false, tx.clone());
|
||||
spawn_reader(stderr, true, tx);
|
||||
|
||||
let mut stdout_buf = String::new();
|
||||
let mut stderr_buf = String::new();
|
||||
let mut eofs = 0usize;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut timed_out = false;
|
||||
|
||||
// 主线程负责消费消息(并调用 on_update),避免跨线程借用。
|
||||
// The main thread consumes messages (calling on_update), avoiding
|
||||
// cross-thread borrows.
|
||||
while eofs < 2 {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
timed_out = true;
|
||||
break;
|
||||
}
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok(StreamMsg::Line(line, is_stderr)) => {
|
||||
if let Some(cb) = on_update {
|
||||
let mut details = JsonValue::obj();
|
||||
details
|
||||
.insert("stream", if is_stderr { "stderr" } else { "stdout" }.into())
|
||||
.ok();
|
||||
cb(ToolUpdate {
|
||||
content: vec![ContentBlock::text(line.clone())],
|
||||
details,
|
||||
});
|
||||
}
|
||||
if is_stderr {
|
||||
stderr_buf.push_str(&line);
|
||||
stderr_buf.push('\n');
|
||||
} else {
|
||||
stdout_buf.push_str(&line);
|
||||
stdout_buf.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(StreamMsg::Eof) => eofs += 1,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
timed_out = true;
|
||||
break;
|
||||
}
|
||||
// 两个读取线程都已退出(通常伴随两个 Eof)。
|
||||
// Both reader threads exited (usually after two Eofs).
|
||||
Err(RecvTimeoutError::Disconnected) => eofs = 2,
|
||||
}
|
||||
}
|
||||
|
||||
if timed_out {
|
||||
// 杀死子进程(注意:不会连带杀掉孙进程,后续版本可扩展)。
|
||||
// Kill the child (note: grandchildren are not killed; a future
|
||||
// version could extend this).
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok((stdout_buf, stderr_buf, -1, true));
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| CoreError::Tool(format!("shell wait: {}", e)))?;
|
||||
let exit_code = status.code().unwrap_or(-1);
|
||||
Ok((stdout_buf, stderr_buf, exit_code, false))
|
||||
}
|
||||
|
||||
/// 启动一个读取线程:逐行读取管道并送入 channel。
|
||||
/// Spawn a reader thread: reads the pipe line-by-line into the channel.
|
||||
fn spawn_reader<R: std::io::Read + Send + 'static>(
|
||||
pipe: R,
|
||||
is_stderr: bool,
|
||||
tx: std::sync::mpsc::Sender<StreamMsg>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(pipe);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(l) => {
|
||||
if tx
|
||||
.send(StreamMsg::Line(
|
||||
l.trim_end_matches(['\r', '\n']).to_string(),
|
||||
is_stderr,
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
let _ = tx.send(StreamMsg::Eof);
|
||||
});
|
||||
}
|
||||
|
||||
/// 截断过长的输出(保留头尾)。
|
||||
/// Truncate overly long output (keeping head and tail).
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let half = max / 2;
|
||||
let mut out = s.chars().take(half).collect::<String>();
|
||||
out.push_str("\n... [output truncated] ...\n");
|
||||
out.push_str(&s.chars().skip(s.len() - half).collect::<String>());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use focus_core::tool::ToolUpdate;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn cmd_args(command: &str) -> JsonValue {
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("command", command.into()).ok();
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runs_bash_and_captures_stdout() {
|
||||
let dir = temp_dir("stdout");
|
||||
let tool = ShellTool::new(&dir);
|
||||
let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap();
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert_eq!(text.trim(), "hello");
|
||||
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
|
||||
assert_eq!(r.details.get_bool("timedOut"), Some(false));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn captures_stderr_and_exit_code() {
|
||||
let dir = temp_dir("stderr");
|
||||
let tool = ShellTool::new(&dir);
|
||||
let r = tool
|
||||
.execute("id", &cmd_args("echo oops >&2; exit 3"), None)
|
||||
.unwrap();
|
||||
assert_eq!(r.details.get_num("exitCode"), Some(3.0));
|
||||
let stderr = r.details.get_str("stderr").unwrap_or("");
|
||||
assert!(stderr.contains("oops"), "got: {}", stderr);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn times_out_and_kills() {
|
||||
let dir = temp_dir("timeout");
|
||||
let tool = ShellTool::new(&dir);
|
||||
let mut args = cmd_args("sleep 5");
|
||||
args.insert("timeoutMs", 200u64.into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
assert_eq!(r.details.get_bool("timedOut"), Some(true));
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert!(text.contains("timed out"), "got: {}", text);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn streams_updates_line_by_line() {
|
||||
let dir = temp_dir("updates");
|
||||
let tool = ShellTool::new(&dir);
|
||||
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink: Box<dyn Fn(ToolUpdate) + Send + Sync> = {
|
||||
let lines = lines.clone();
|
||||
Box::new(move |u: ToolUpdate| {
|
||||
if let Some(t) = u.content[0].as_text() {
|
||||
lines.lock().unwrap().push(t.text.clone());
|
||||
}
|
||||
})
|
||||
};
|
||||
let r = tool
|
||||
.execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink))
|
||||
.unwrap();
|
||||
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
|
||||
let got = lines.lock().unwrap();
|
||||
assert_eq!(
|
||||
*got,
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runs_in_project_root() {
|
||||
let dir = temp_dir("cwd");
|
||||
let tool = ShellTool::new(&dir);
|
||||
let r = tool.execute("id", &cmd_args("pwd"), None).unwrap();
|
||||
let text = r.content[0].as_text().unwrap().text.clone();
|
||||
assert_eq!(text.trim(), dir.display().to_string());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
//! `write` 工具:整文件覆盖写入,自动创建父目录。
|
||||
//! The `write` tool: whole-file overwrite, auto-creating parent directories.
|
||||
|
||||
use crate::resolve_path;
|
||||
use focus_core::model::ContentBlock;
|
||||
use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink};
|
||||
use focus_core::{CoreError, CoreResult};
|
||||
use focus_json::JsonValue;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 覆盖写入文件的工具。
|
||||
/// A tool that overwrites files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WriteTool {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl WriteTool {
|
||||
/// 以项目根目录构造工具。
|
||||
/// Build the tool with a project root.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for WriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Write text to a file, creating parent directories as needed. \
|
||||
Overwrites any existing content."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> JsonValue {
|
||||
let mut o = JsonValue::obj();
|
||||
o.insert("type", "object".into()).ok();
|
||||
let mut props = JsonValue::obj();
|
||||
let mut path = JsonValue::obj();
|
||||
path.insert("type", "string".into()).ok();
|
||||
props.insert("path", path).ok();
|
||||
let mut content = JsonValue::obj();
|
||||
content.insert("type", "string".into()).ok();
|
||||
props.insert("content", content).ok();
|
||||
o.insert("properties", props).ok();
|
||||
let mut required = JsonValue::arr();
|
||||
required.push("path".into()).ok();
|
||||
required.push("content".into()).ok();
|
||||
o.insert("required", required).ok();
|
||||
o
|
||||
}
|
||||
|
||||
fn effects(&self) -> ToolEffects {
|
||||
ToolEffects::WRITE
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_id: &str,
|
||||
args: &JsonValue,
|
||||
_on_update: Option<&ToolUpdateSink>,
|
||||
) -> CoreResult<ToolResult> {
|
||||
let path = args
|
||||
.get_str("path")
|
||||
.ok_or_else(|| CoreError::InvalidArgs("write: missing 'path'".into()))?;
|
||||
let content = args
|
||||
.get_str("content")
|
||||
.ok_or_else(|| CoreError::InvalidArgs("write: missing 'content'".into()))?;
|
||||
let resolved = resolve_path(&self.root, path);
|
||||
|
||||
if let Some(parent) = resolved.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
CoreError::Tool(format!(
|
||||
"write {}: create parent dirs: {}",
|
||||
resolved.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
std::fs::write(&resolved, content.as_bytes())
|
||||
.map_err(|e| CoreError::Tool(format!("write {}: {}", resolved.display(), e)))?;
|
||||
|
||||
let mut details = JsonValue::obj();
|
||||
details
|
||||
.insert("path", resolved.display().to_string().into())
|
||||
.ok();
|
||||
details.insert("bytes", (content.len() as f64).into()).ok();
|
||||
Ok(ToolResult {
|
||||
content: vec![ContentBlock::text(format!(
|
||||
"Wrote {} bytes to {}",
|
||||
content.len(),
|
||||
resolved.display()
|
||||
))],
|
||||
details,
|
||||
added_tool_names: Vec::new(),
|
||||
terminate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_and_overwrites() {
|
||||
let dir = temp_dir("overwrite");
|
||||
fs::write(dir.join("a.txt"), "old").unwrap();
|
||||
let tool = WriteTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "a.txt".into()).ok();
|
||||
args.insert("content", "new content".into()).ok();
|
||||
let r = tool.execute("id", &args, None).unwrap();
|
||||
assert!(r.content[0].as_text().unwrap().text.contains("11 bytes"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("a.txt")).unwrap(),
|
||||
"new content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_parent_directories() {
|
||||
let dir = temp_dir("nested");
|
||||
let tool = WriteTool::new(&dir);
|
||||
let mut args = JsonValue::obj();
|
||||
args.insert("path", "deep/nested/file.txt".into()).ok();
|
||||
args.insert("content", "hi".into()).ok();
|
||||
tool.execute("id", &args, None).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(),
|
||||
"hi"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue