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:
DaiChaoXiong 2026-08-09 20:59:54 +08:00
parent 8ed56c3952
commit ee165235ec
6 changed files with 1076 additions and 7 deletions

View File

@ -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

View File

@ -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(&current[..chosen]);
next.push_str(new_string);
next.push_str(&current[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");
}
}

View File

@ -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)
}
}

View File

@ -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");
}
}

View File

@ -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());
}
}

View File

@ -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"
);
}
}