Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "nomi-tools"
|
||||
description = "Built-in agent tools for Nomi (Read, Write, Edit, Bash, Grep, Glob, Spawn)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Cross-platform test fixture spawned by the PTY/process unit tests (replaces
|
||||
# the unix-only `cat`/`sleep`/`sh` programs). Built with the crate so unit tests
|
||||
# in `src/` can locate it next to the test runner via `current_exe()`.
|
||||
[[bin]]
|
||||
name = "pty_test_helper"
|
||||
path = "src/bin/pty_test_helper.rs"
|
||||
|
||||
[dependencies]
|
||||
nomi-types.workspace = true
|
||||
nomi-protocol.workspace = true
|
||||
nomi-config.workspace = true
|
||||
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
glob.workspace = true
|
||||
lru.workspace = true
|
||||
portable-pty.workspace = true
|
||||
libc.workspace = true
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,521 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::edit::{EditOp, apply_edits};
|
||||
use crate::file_cache::{FileStateCache, file_mtime_ms, update_cache_after_write};
|
||||
|
||||
/// Apply edits to SEVERAL files in one call. Every file is read and validated
|
||||
/// first; writes happen only if every file's edits apply cleanly — so a
|
||||
/// non-matching hunk in any file aborts the whole patch with nothing written
|
||||
/// (all-or-nothing across files for the common failure mode). Cuts the N-call
|
||||
/// cost of a cross-file refactor to one. Reuses the single-file apply_edits
|
||||
/// engine and atomic per-file writes.
|
||||
pub struct ApplyPatchTool {
|
||||
file_cache: Option<Arc<RwLock<FileStateCache>>>,
|
||||
/// Optional containment root; when set, patches outside it are rejected.
|
||||
write_root: Option<std::path::PathBuf>,
|
||||
/// Session working directory used to resolve relative `file_path` inputs
|
||||
/// (matching ReadTool / Grep / Glob / Bash). `None` = legacy process-cwd.
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
fn err(msg: impl Into<String>) -> ToolResult {
|
||||
ToolResult {
|
||||
content: msg.into(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplyPatchTool {
|
||||
pub fn new(file_cache: Option<Arc<RwLock<FileStateCache>>>) -> Self {
|
||||
Self {
|
||||
file_cache,
|
||||
write_root: None,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict patched files to within `root` (design §3.6 write-root containment).
|
||||
pub fn with_write_root(mut self, root: Option<std::path::PathBuf>) -> Self {
|
||||
self.write_root = root;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve relative `file_path` inputs against `cwd` (the session working
|
||||
/// directory), matching ReadTool/Grep/Glob/Bash.
|
||||
pub fn with_cwd(mut self, cwd: Option<std::path::PathBuf>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
|
||||
/// Must-Read-first + staleness guard for one file (mirrors EditTool). Returns
|
||||
/// `Some(error)` if rejected, `None` if OK or no cache is wired.
|
||||
fn cache_guard(&self, path: &Path) -> Option<String> {
|
||||
let cache_arc = self.file_cache.as_ref()?;
|
||||
let mut cache = cache_arc.write().ok()?;
|
||||
let cached = cache.get(path);
|
||||
if cached.is_none() {
|
||||
return Some(format!(
|
||||
"You must Read {} before patching it.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let cached_mtime = cached.map(|s| s.mtime_ms);
|
||||
let disk_mtime = file_mtime_ms(path);
|
||||
if let (Some(c), Some(d)) = (cached_mtime, disk_mtime)
|
||||
&& c != d
|
||||
{
|
||||
return Some(format!(
|
||||
"File {} changed on disk since last read; Read it again before patching.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ApplyPatchTool {
|
||||
fn name(&self) -> &str {
|
||||
"ApplyPatch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Apply edits across MULTIPLE files in a single call (atomic for the common\n\
|
||||
failure mode: if any file's edits do not apply cleanly, nothing is written).\n\n\
|
||||
Usage:\n\
|
||||
- Each file is either {file_path, edits:[...]} to patch an existing file, or \
|
||||
{file_path, content:\"...\"} to create a new file (or replace one whole).\n\
|
||||
- Read each file first before using `edits`.\n\
|
||||
- Prefer this over many separate Edit/Write calls when one change spans files.\n\
|
||||
- Each file's `edits` is a list of {old_string, new_string, replace_all?} applied in order; \
|
||||
each old_string must be unique in that file (or set replace_all)."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "array",
|
||||
"description": "Files to patch or create. Each is either {file_path, edits:[{old_string,new_string,replace_all?}]} to patch an existing file, or {file_path, content} to create/replace a whole file.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": { "type": "string", "description": "Path to the file (absolute preferred; a relative path resolves against the session working directory)" },
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full file content. Use to CREATE a new file or replace an existing one whole. Mutually exclusive with `edits`."
|
||||
},
|
||||
"edits": {
|
||||
"type": "array",
|
||||
"description": "Patch an existing file. Mutually exclusive with `content`.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"old_string": { "type": "string" },
|
||||
"new_string": { "type": "string" },
|
||||
"replace_all": { "type": "boolean" }
|
||||
},
|
||||
"required": ["old_string", "new_string"]
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"type": "boolean",
|
||||
"description": "Delete the file. Mutually exclusive with `content`/`edits`. The file must exist (and have been read first)."
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["files"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(files) = input["files"].as_array() else {
|
||||
return err("Missing required parameter: files (array)");
|
||||
};
|
||||
if files.is_empty() {
|
||||
return err("files array must not be empty");
|
||||
}
|
||||
|
||||
// PHASE 1 — validate + compute the new content for every file. No writes.
|
||||
let mut planned: Vec<(String, String)> = Vec::with_capacity(files.len());
|
||||
let mut to_delete: Vec<String> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
let mut created = 0usize;
|
||||
for (i, f) in files.iter().enumerate() {
|
||||
let Some(file_path) = f["file_path"].as_str() else {
|
||||
return err(format!("file #{}: missing file_path", i + 1));
|
||||
};
|
||||
// Resolve a relative file_path against the session working directory
|
||||
// (matching ReadTool/Grep/Glob/Bash) before validating/writing.
|
||||
let resolved = crate::path_guard::resolve_against_cwd(file_path, self.cwd.as_deref());
|
||||
let file_path = resolved.as_str();
|
||||
// Write-root containment (opt-in): reject any file outside the root
|
||||
// before validating/writing anything (keeps the all-or-nothing
|
||||
// guarantee — a single out-of-root file aborts the whole patch).
|
||||
if let Some(msg) = crate::path_guard::ensure_within_root(file_path, self.write_root.as_deref()) {
|
||||
return err(msg);
|
||||
}
|
||||
let content_field = f.get("content").and_then(|v| v.as_str());
|
||||
let edits_field = f.get("edits").and_then(|v| v.as_array());
|
||||
let delete_field = f.get("delete").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Delete: remove the file. Mutually exclusive with content/edits, must
|
||||
// exist, and (with a cache wired) must have been read first.
|
||||
if delete_field {
|
||||
if content_field.is_some() || edits_field.is_some() {
|
||||
return err(format!(
|
||||
"{}: `delete` cannot be combined with `content` or `edits`",
|
||||
file_path
|
||||
));
|
||||
}
|
||||
let path = Path::new(file_path);
|
||||
if !path.exists() {
|
||||
return err(format!("{}: cannot delete — file does not exist", file_path));
|
||||
}
|
||||
if let Some(msg) = self.cache_guard(path) {
|
||||
return err(msg);
|
||||
}
|
||||
to_delete.push(file_path.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
match (content_field, edits_field) {
|
||||
(Some(_), Some(_)) => {
|
||||
return err(format!(
|
||||
"{}: specify either `content` (create/replace whole file) or `edits` (patch existing), not both",
|
||||
file_path
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return err(format!("{}: each file needs either `content` or `edits`", file_path));
|
||||
}
|
||||
// Create or replace the whole file with `content`.
|
||||
(Some(content), None) => {
|
||||
let path = Path::new(file_path);
|
||||
// Overwriting an existing file requires must-read-first (mirrors
|
||||
// edits / WriteTool). Creating a new file reads nothing, so no
|
||||
// guard applies.
|
||||
if path.exists()
|
||||
&& let Some(msg) = self.cache_guard(path)
|
||||
{
|
||||
return err(msg);
|
||||
}
|
||||
if !path.exists() {
|
||||
created += 1;
|
||||
}
|
||||
planned.push((file_path.to_string(), content.to_string()));
|
||||
}
|
||||
// Patch an existing file with `edits`.
|
||||
(None, Some(edits_arr)) => {
|
||||
if edits_arr.is_empty() {
|
||||
return err(format!("{}: edits array must not be empty", file_path));
|
||||
}
|
||||
let mut ops = Vec::with_capacity(edits_arr.len());
|
||||
for e in edits_arr {
|
||||
let (Some(o), Some(n)) = (e["old_string"].as_str(), e["new_string"].as_str())
|
||||
else {
|
||||
return err(format!("{}: each edit needs old_string and new_string", file_path));
|
||||
};
|
||||
ops.push(EditOp {
|
||||
old_string: o.to_string(),
|
||||
new_string: n.to_string(),
|
||||
replace_all: e["replace_all"].as_bool().unwrap_or(false),
|
||||
});
|
||||
}
|
||||
let path = Path::new(file_path);
|
||||
if let Some(msg) = self.cache_guard(path) {
|
||||
return err(msg);
|
||||
}
|
||||
let content = match std::fs::read_to_string(file_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return err(format!("Failed to read {}: {}", file_path, e)),
|
||||
};
|
||||
match apply_edits(&content, &ops) {
|
||||
Ok((new_content, n)) => {
|
||||
total += n;
|
||||
planned.push((file_path.to_string(), new_content));
|
||||
}
|
||||
// Abort: a hunk did not apply — leave ALL files untouched.
|
||||
Err(msg) => return err(format!("{}: {}", file_path, msg)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2 — every file validated; commit writes atomically per file.
|
||||
for (path_str, new_content) in &planned {
|
||||
// Create the parent directory so a `content` create into a new
|
||||
// subdirectory succeeds (no-op when it already exists).
|
||||
if let Some(parent) = Path::new(path_str).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = crate::atomic_write(path_str, new_content) {
|
||||
return err(format!("Failed to write {}: {}", path_str, e));
|
||||
}
|
||||
if let Some(cache_arc) = &self.file_cache {
|
||||
update_cache_after_write(cache_arc, Path::new(path_str), new_content);
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2b — deletions (after writes; independent paths, order-agnostic).
|
||||
for path_str in &to_delete {
|
||||
if let Err(e) = std::fs::remove_file(path_str) {
|
||||
return err(format!("Failed to delete {}: {}", path_str, e));
|
||||
}
|
||||
if let Some(cache_arc) = &self.file_cache
|
||||
&& let Ok(mut cache) = cache_arc.write()
|
||||
{
|
||||
cache.remove(Path::new(path_str));
|
||||
}
|
||||
}
|
||||
|
||||
ToolResult {
|
||||
content: format!(
|
||||
"Applied patch to {} file(s) ({} created, {} deleted, {} total replacement(s))",
|
||||
planned.len() + to_delete.len(),
|
||||
created,
|
||||
to_delete.len(),
|
||||
total
|
||||
),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn max_result_size(&self) -> usize {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Edit
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let n = input
|
||||
.get("files")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
format!("ApplyPatch across {} file(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_resolves_relative_path_against_cwd() {
|
||||
// A relative file_path in a patch must resolve against the injected
|
||||
// workspace cwd, not the process cwd.
|
||||
let workspace = tempdir().unwrap();
|
||||
let rel = "__nomi_reltest_patch__.txt";
|
||||
let tool = ApplyPatchTool::new(None).with_cwd(Some(workspace.path().to_path_buf()));
|
||||
let result = tool
|
||||
.execute(json!({ "files": [{ "file_path": rel, "content": "fresh\n" }] }))
|
||||
.await;
|
||||
assert!(!result.is_error, "relative create should succeed: {}", result.content);
|
||||
assert!(
|
||||
workspace.path().join(rel).exists(),
|
||||
"relative create must land in the workspace, not the process cwd"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.path().join(rel)).unwrap(),
|
||||
"fresh\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_patches_multiple_files_in_one_call() {
|
||||
let dir = tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, "alpha").unwrap();
|
||||
std::fs::write(&b, "beta").unwrap();
|
||||
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [
|
||||
{ "file_path": a.to_str().unwrap(), "edits": [{ "old_string": "alpha", "new_string": "A" }] },
|
||||
{ "file_path": b.to_str().unwrap(), "edits": [{ "old_string": "beta", "new_string": "B" }] }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert!(!result.is_error, "unexpected: {}", result.content);
|
||||
assert_eq!(std::fs::read_to_string(&a).unwrap(), "A");
|
||||
assert_eq!(std::fs::read_to_string(&b).unwrap(), "B");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_aborts_all_files_when_one_hunk_fails() {
|
||||
let dir = tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, "alpha").unwrap();
|
||||
std::fs::write(&b, "beta").unwrap();
|
||||
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [
|
||||
{ "file_path": a.to_str().unwrap(), "edits": [{ "old_string": "alpha", "new_string": "A" }] },
|
||||
{ "file_path": b.to_str().unwrap(), "edits": [{ "old_string": "NOPE", "new_string": "x" }] }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert!(result.is_error, "a failing hunk must fail the whole patch");
|
||||
// Atomic across files: the first (valid) file must NOT have been written.
|
||||
assert_eq!(std::fs::read_to_string(&a).unwrap(), "alpha");
|
||||
assert_eq!(std::fs::read_to_string(&b).unwrap(), "beta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_creates_new_file_via_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let new_file = dir.path().join("sub/created.txt");
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [
|
||||
{ "file_path": new_file.to_str().unwrap(), "content": "fresh contents\n" }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert!(!result.is_error, "create should succeed: {}", result.content);
|
||||
// Parent dir is created as needed.
|
||||
assert_eq!(std::fs::read_to_string(&new_file).unwrap(), "fresh contents\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_content_and_edits_are_mutually_exclusive() {
|
||||
let dir = tempdir().unwrap();
|
||||
let f = dir.path().join("f.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [{
|
||||
"file_path": f.to_str().unwrap(),
|
||||
"content": "y",
|
||||
"edits": [{ "old_string": "x", "new_string": "z" }]
|
||||
}]
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_error, "specifying both content and edits must be rejected");
|
||||
// Nothing written.
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "x");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_create_is_atomic_with_a_failing_edit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let existing = dir.path().join("e.txt");
|
||||
let created = dir.path().join("created.txt");
|
||||
std::fs::write(&existing, "alpha").unwrap();
|
||||
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [
|
||||
{ "file_path": created.to_str().unwrap(), "content": "should not survive" },
|
||||
{ "file_path": existing.to_str().unwrap(), "edits": [{ "old_string": "NOPE", "new_string": "x" }] }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert!(result.is_error, "a failing edit must abort the whole patch");
|
||||
// The create must NOT have happened (validate-all before write-any).
|
||||
assert!(!created.exists(), "new file must not exist when the patch aborts");
|
||||
assert_eq!(std::fs::read_to_string(&existing).unwrap(), "alpha");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_deletes_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let f = dir.path().join("gone.txt");
|
||||
std::fs::write(&f, "bye").unwrap();
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [{ "file_path": f.to_str().unwrap(), "delete": true }]
|
||||
}))
|
||||
.await;
|
||||
assert!(!result.is_error, "delete should succeed: {}", result.content);
|
||||
assert!(!f.exists(), "file must be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_delete_rejects_missing_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let f = dir.path().join("nope.txt");
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [{ "file_path": f.to_str().unwrap(), "delete": true }]
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_error, "deleting a non-existent file must error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_delete_is_atomic_with_a_failing_edit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let doomed = dir.path().join("doomed.txt");
|
||||
let other = dir.path().join("other.txt");
|
||||
std::fs::write(&doomed, "still here").unwrap();
|
||||
std::fs::write(&other, "alpha").unwrap();
|
||||
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [
|
||||
{ "file_path": doomed.to_str().unwrap(), "delete": true },
|
||||
{ "file_path": other.to_str().unwrap(), "edits": [{ "old_string": "NOPE", "new_string": "x" }] }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_error, "a failing edit must abort the whole patch");
|
||||
assert!(doomed.exists(), "file must NOT be deleted when the patch aborts");
|
||||
assert_eq!(std::fs::read_to_string(&doomed).unwrap(), "still here");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_patch_delete_is_mutually_exclusive_with_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let f = dir.path().join("f.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let tool = ApplyPatchTool::new(None);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"files": [{ "file_path": f.to_str().unwrap(), "delete": true, "content": "y" }]
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_error, "delete + content must be rejected");
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "x", "nothing written/deleted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_config::shell::shell_command_builder;
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::output_truncation::{truncate_middle, TruncationBudget};
|
||||
use crate::Tool;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
||||
const MAX_TIMEOUT_MS: u64 = 600_000;
|
||||
/// Per-stream byte budget for Bash output before head/tail elision. Matches
|
||||
/// `Tool::max_result_size()` so the engine-level fallback rarely fires.
|
||||
const BASH_OUTPUT_MAX_BYTES: usize = 50_000;
|
||||
|
||||
pub struct BashTool {
|
||||
cwd: PathBuf,
|
||||
/// When set, commands run in a long-lived shell session so cwd/env persist
|
||||
/// across calls (Unix-only, dark-launch). `None` → stateless one-shot.
|
||||
#[cfg(unix)]
|
||||
persistent: Option<std::sync::Arc<crate::persistent_shell::PersistentShell>>,
|
||||
/// When set (macOS only), commands run under a Seatbelt write-containment
|
||||
/// sandbox allowing writes only to these roots (+ temp/devices). `None` = off.
|
||||
sandbox_roots: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl BashTool {
|
||||
pub fn new(cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
#[cfg(unix)]
|
||||
persistent: None,
|
||||
sandbox_roots: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a `Bash` tool backed by a persistent shell session. cwd/env
|
||||
/// mutations persist across calls. Unix-only.
|
||||
#[cfg(unix)]
|
||||
pub fn with_persistent_shell(
|
||||
cwd: PathBuf,
|
||||
shell: std::sync::Arc<crate::persistent_shell::PersistentShell>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
persistent: Some(shell),
|
||||
sandbox_roots: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run commands under a macOS Seatbelt write-containment sandbox, allowing
|
||||
/// writes only to `roots` (plus temp dirs and the standard devices). No-op
|
||||
/// on non-macOS. (§3.6 OS sandbox)
|
||||
pub fn with_sandbox(mut self, roots: Option<Vec<PathBuf>>) -> Self {
|
||||
self.sandbox_roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run `command` in the persistent shell session and format the result with
|
||||
/// the same envelope as the one-shot path. stdout/stderr are PTY-interleaved
|
||||
/// (a single stream), so they are reported together.
|
||||
#[cfg(unix)]
|
||||
async fn execute_persistent(
|
||||
&self,
|
||||
shell: &crate::persistent_shell::PersistentShell,
|
||||
command: &str,
|
||||
timeout_ms: u64,
|
||||
) -> ToolResult {
|
||||
match shell
|
||||
.run(command, Duration::from_millis(timeout_ms))
|
||||
.await
|
||||
{
|
||||
Ok(outcome) if outcome.timed_out => ToolResult {
|
||||
content: format!(
|
||||
"Command timed out after {}ms (the shell was interrupted).\nPartial output:\n{}",
|
||||
timeout_ms,
|
||||
truncate_middle(&outcome.output, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES)),
|
||||
),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
Ok(outcome) => {
|
||||
let output =
|
||||
truncate_middle(&outcome.output, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES));
|
||||
ToolResult {
|
||||
content: format!("Exit code: {}\nOUTPUT:\n{}", outcome.exit_code, output),
|
||||
is_error: outcome.exit_code != 0,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
Err(e) => ToolResult {
|
||||
content: format!("Failed to run command in persistent shell: {e}"),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `command` under a macOS Seatbelt write-containment sandbox
|
||||
/// (`sandbox-exec`), allowing writes only to `roots` (+ temp/devices).
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn execute_sandboxed(&self, roots: &[PathBuf], command: &str, timeout_ms: u64) -> ToolResult {
|
||||
let profile = crate::sandbox::write_sandbox_profile(roots);
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let cwd = self.cwd.clone();
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let mut cmd = tokio::process::Command::new("/usr/bin/sandbox-exec");
|
||||
cmd.arg("-p")
|
||||
.arg(&profile)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(&cwd);
|
||||
// Strip dynamic-linker injection vars from the sandboxed subprocess.
|
||||
crate::sandbox::harden_env(&mut cmd);
|
||||
cmd.output().await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
let stdout = truncate_middle(&stdout, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES));
|
||||
let stderr =
|
||||
truncate_middle(&stderr, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES / 2));
|
||||
ToolResult {
|
||||
content: format!(
|
||||
"Exit code: {} [sandboxed]\nSTDOUT:\n{}\nSTDERR:\n{}",
|
||||
exit_code, stdout, stderr
|
||||
),
|
||||
is_error: exit_code != 0,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => ToolResult {
|
||||
content: format!("Failed to execute sandboxed command: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
Err(_) => ToolResult {
|
||||
content: format!("Command timed out after {}ms", timeout_ms),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BashTool {
|
||||
fn name(&self) -> &str {
|
||||
"Bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Executes a shell command and returns its output.\n\n\
|
||||
IMPORTANT: Do NOT use Bash when a dedicated tool is available:\n\
|
||||
- File search: use Glob (not find or ls)\n\
|
||||
- Content search: use Grep (not grep or rg)\n\
|
||||
- Read files: use Read (not cat, head, or tail)\n\
|
||||
- Edit files: use Edit (not sed or awk)\n\
|
||||
- Write files: use Write (not echo or cat with heredoc)\n\n\
|
||||
# Instructions\n\
|
||||
- Use absolute paths to avoid working directory confusion.\n\
|
||||
- When issuing multiple independent commands, make parallel tool calls \
|
||||
instead of chaining them. Use `&&` only when commands depend on each other.\n\
|
||||
- You may specify an optional timeout in milliseconds (default 120000, max 600000).\n\n\
|
||||
# Git safety\n\
|
||||
- Never force push, reset --hard, or use --no-verify unless explicitly asked.\n\
|
||||
- Prefer creating new commits over amending existing ones."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(command) = input["command"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: command".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
tracing::debug!(cwd = %self.cwd.display(), command = %command, "BashTool executing");
|
||||
|
||||
let timeout_ms = input["timeout"]
|
||||
.as_u64()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.min(MAX_TIMEOUT_MS);
|
||||
|
||||
// macOS Seatbelt write-containment sandbox (opt-in): takes precedence so
|
||||
// arbitrary subprocesses are confined. Falls through if unsupported.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(roots) = &self.sandbox_roots {
|
||||
if crate::sandbox::is_supported() {
|
||||
return self.execute_sandboxed(roots, command, timeout_ms).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent-shell path (Unix, dark-launch): cwd/env persist across calls.
|
||||
#[cfg(unix)]
|
||||
if let Some(shell) = &self.persistent {
|
||||
return self.execute_persistent(shell, command, timeout_ms).await;
|
||||
}
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
let cwd = self.cwd.clone();
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
shell_command_builder(command)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
|
||||
// Bound each stream independently so a noisy stdout can't crowd
|
||||
// out stderr (and vice versa); head/tail elision keeps both ends.
|
||||
let stdout = truncate_middle(&stdout, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES));
|
||||
let stderr =
|
||||
truncate_middle(&stderr, TruncationBudget::Bytes(BASH_OUTPUT_MAX_BYTES / 2));
|
||||
|
||||
let content = format!(
|
||||
"Exit code: {}\nSTDOUT:\n{}\nSTDERR:\n{}",
|
||||
exit_code, stdout, stderr
|
||||
);
|
||||
|
||||
ToolResult {
|
||||
content,
|
||||
is_error: exit_code != 0,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => ToolResult {
|
||||
content: format!("Failed to execute command: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
Err(_) => ToolResult {
|
||||
content: format!("Command timed out after {}ms", timeout_ms),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Exec
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
format!("Execute: {}", crate::truncate_utf8(cmd, 80))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_echo_returns_stdout() {
|
||||
let tool = BashTool::new(std::env::temp_dir());
|
||||
let input = json!({"command": "echo hello_bash"});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(result.content.contains("hello_bash"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_invalid_command_returns_error() {
|
||||
let tool = BashTool::new(std::env::temp_dir());
|
||||
let input = json!({"command": "nonexistent_command_xyz_123"});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(result.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_respects_cwd() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("cwd_proof.txt"), "proof").unwrap();
|
||||
let tool = BashTool::new(dir.path().to_path_buf());
|
||||
let cmd = if cfg!(windows) {
|
||||
"type cwd_proof.txt"
|
||||
} else {
|
||||
"cat cwd_proof.txt"
|
||||
};
|
||||
let input = json!({"command": cmd});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(
|
||||
result.content.contains("proof"),
|
||||
"BashTool should execute in injected cwd, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn persistent_shell_path_persists_cwd_across_calls() {
|
||||
use std::sync::Arc;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("inner");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let shell = Arc::new(crate::persistent_shell::PersistentShell::new(
|
||||
dir.path().to_string_lossy().into_owned(),
|
||||
));
|
||||
let tool = BashTool::with_persistent_shell(dir.path().to_path_buf(), shell);
|
||||
|
||||
// First call changes directory; second must observe it (one-shot Bash
|
||||
// would not — this is the persistent-shell guarantee).
|
||||
let cd = tool.execute(json!({"command": format!("cd {}", sub.display())})).await;
|
||||
assert!(!cd.is_error, "cd failed: {}", cd.content);
|
||||
let pwd = tool.execute(json!({"command": "pwd"})).await;
|
||||
assert!(
|
||||
pwd.content.contains("inner"),
|
||||
"cwd must persist across Bash calls in persistent mode, got: {}",
|
||||
pwd.content
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn sandbox_blocks_writes_outside_the_workspace_root() {
|
||||
if !crate::sandbox::is_supported() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let canon = root.path().canonicalize().unwrap();
|
||||
let tool = BashTool::new(canon.clone()).with_sandbox(Some(vec![canon.clone()]));
|
||||
|
||||
// Write inside the workspace → allowed.
|
||||
let inside = canon.join("inside.txt");
|
||||
let ok = tool
|
||||
.execute(json!({ "command": format!("echo hi > {}", inside.display()) }))
|
||||
.await;
|
||||
assert!(!ok.is_error, "in-root write should succeed: {}", ok.content);
|
||||
assert!(inside.exists());
|
||||
|
||||
// Write to $HOME (outside) → blocked by the sandbox (non-zero exit).
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
let outside = std::path::Path::new(&home).join(".nomi_bash_sandbox_escape.txt");
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
let denied = tool
|
||||
.execute(json!({ "command": format!("echo hi > {}", outside.display()) }))
|
||||
.await;
|
||||
let escaped = outside.exists();
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
assert!(denied.is_error, "out-of-root write should report failure");
|
||||
assert!(!escaped, "out-of-root write must be blocked by the sandbox");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Cross-platform test fixture for the `nomi-tools` PTY/process unit tests.
|
||||
//!
|
||||
//! The PTY tests need a handful of deterministic child behaviours (echo stdin,
|
||||
//! stay alive for N ms, exit with a code, emit output after a delay). The unix
|
||||
//! programs they originally used (`cat`, `sleep`, `sh -c 'exit N'`) do not exist
|
||||
//! under Windows `cmd`, so the tests spawn THIS binary instead — identical
|
||||
//! behaviour on Windows and unix, no external dependencies, pure `std`.
|
||||
//!
|
||||
//! Subcommands (`pty_test_helper <subcommand> [args...]`):
|
||||
//! - `echo-stdin` read stdin line-by-line, echo each line to
|
||||
//! stdout (flushed), exit on EOF. Replaces `cat`.
|
||||
//! - `sleep <ms>` sleep `ms` milliseconds, then exit 0.
|
||||
//! Replaces `sleep N`.
|
||||
//! - `exit <code>` exit immediately with `code`.
|
||||
//! Replaces `sh -c 'exit N'`.
|
||||
//! - `emit-after <ms> <text> <keepalive_ms>`
|
||||
//! sleep `ms`, print `text` + newline (flushed),
|
||||
//! then sleep `keepalive_ms` and exit. Models a
|
||||
//! process that emits delayed output then lingers.
|
||||
//!
|
||||
//! Kept dependency-free on purpose: it is compiled as part of the crate's normal
|
||||
//! build (a `[[bin]]`) so the unit tests can locate it next to the test runner.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let sub = args.first().map(String::as_str).unwrap_or("");
|
||||
|
||||
match sub {
|
||||
"echo-stdin" => echo_stdin(),
|
||||
"sleep" => {
|
||||
let ms = parse_u64(args.get(1), "sleep <ms>");
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
}
|
||||
"exit" => {
|
||||
let code = parse_i32(args.get(1), "exit <code>");
|
||||
std::process::exit(code);
|
||||
}
|
||||
"emit-after" => {
|
||||
let delay_ms = parse_u64(args.get(1), "emit-after <ms> <text> <keepalive_ms>");
|
||||
let text = args.get(2).cloned().unwrap_or_default();
|
||||
let keepalive_ms = parse_u64(args.get(3), "emit-after <ms> <text> <keepalive_ms>");
|
||||
std::thread::sleep(Duration::from_millis(delay_ms));
|
||||
let stdout = std::io::stdout();
|
||||
let mut w = stdout.lock();
|
||||
let _ = writeln!(w, "{text}");
|
||||
let _ = w.flush();
|
||||
drop(w);
|
||||
std::thread::sleep(Duration::from_millis(keepalive_ms));
|
||||
}
|
||||
other => {
|
||||
eprintln!("pty_test_helper: unknown subcommand {other:?}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read stdin line-by-line and echo each line back to stdout, flushing after
|
||||
/// every line so a PTY consumer sees the echo promptly. Exits on EOF. This is
|
||||
/// the cross-platform stand-in for `cat`.
|
||||
fn echo_stdin() {
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
let mut input = stdin.lock();
|
||||
let mut out = stdout.lock();
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match input.read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
// `read_line` keeps the trailing newline; write it back verbatim.
|
||||
if out.write_all(line.as_bytes()).is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = out.flush();
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(arg: Option<&String>, usage: &str) -> u64 {
|
||||
match arg.and_then(|s| s.parse::<u64>().ok()) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
eprintln!("pty_test_helper: expected {usage}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_i32(arg: Option<&String>, usage: &str) -> i32 {
|
||||
match arg.and_then(|s| s.parse::<i32>().ok()) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
eprintln!("pty_test_helper: expected {usage}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::file_cache::{FileStateCache, file_mtime_ms, update_cache_after_write};
|
||||
|
||||
/// A single find/replace operation within a file.
|
||||
pub(crate) struct EditOp {
|
||||
pub old_string: String,
|
||||
pub new_string: String,
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
/// Apply a sequence of edits to `content` in order, returning the new content
|
||||
/// and the total number of replacements. All-or-nothing: if any hunk fails to
|
||||
/// match (or is ambiguous without `replace_all`), returns `Err` and the caller
|
||||
/// MUST NOT write — so a multi-edit never leaves a file partially modified.
|
||||
/// Later hunks see the text produced by earlier ones (sequential semantics).
|
||||
/// Single-edit error messages stay unprefixed for backward compatibility.
|
||||
pub(crate) fn apply_edits(content: &str, ops: &[EditOp]) -> Result<(String, usize), String> {
|
||||
let multi = ops.len() > 1;
|
||||
let mut current = content.to_string();
|
||||
let mut total = 0usize;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let label = if multi { format!("edit #{}: ", i + 1) } else { String::new() };
|
||||
let count = current.matches(&op.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(format!("{label}old_string not found in file"));
|
||||
}
|
||||
if count > 1 && !op.replace_all {
|
||||
return Err(format!(
|
||||
"{label}Multiple matches found ({count}). Use replace_all or provide more context."
|
||||
));
|
||||
}
|
||||
current = if op.replace_all {
|
||||
current.replace(&op.old_string, &op.new_string)
|
||||
} else {
|
||||
current.replacen(&op.old_string, &op.new_string, 1)
|
||||
};
|
||||
total += if op.replace_all { count } else { 1 };
|
||||
}
|
||||
Ok((current, total))
|
||||
}
|
||||
|
||||
pub struct EditTool {
|
||||
file_cache: Option<Arc<RwLock<FileStateCache>>>,
|
||||
/// Optional containment root; when set, edits outside it are rejected.
|
||||
write_root: Option<std::path::PathBuf>,
|
||||
/// Session working directory used to resolve relative `file_path` inputs
|
||||
/// (matching ReadTool / Grep / Glob / Bash). `None` = legacy process-cwd.
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl EditTool {
|
||||
/// Create an EditTool with optional file state cache.
|
||||
///
|
||||
/// When cache is `Some`, the tool enforces:
|
||||
/// - "Must Read first" guard (file must be in cache before editing)
|
||||
/// - Staleness detection (disk mtime must match cached mtime)
|
||||
/// - Post-write cache update (mtime + content refreshed after edit)
|
||||
///
|
||||
/// Pass `None` to disable all cache-related guards (legacy behavior).
|
||||
pub fn new(file_cache: Option<Arc<RwLock<FileStateCache>>>) -> Self {
|
||||
Self {
|
||||
file_cache,
|
||||
write_root: None,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict edits to within `root` (design §3.6 write-root containment).
|
||||
pub fn with_write_root(mut self, root: Option<std::path::PathBuf>) -> Self {
|
||||
self.write_root = root;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve relative `file_path` inputs against `cwd` (the session working
|
||||
/// directory), matching ReadTool/Grep/Glob/Bash.
|
||||
pub fn with_cwd(mut self, cwd: Option<std::path::PathBuf>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EditTool {
|
||||
fn name(&self) -> &str {
|
||||
"Edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Performs exact string replacements in files.\n\n\
|
||||
Usage:\n\
|
||||
- You must use the Read tool first before editing a file.\n\
|
||||
- For a single change, pass old_string + new_string.\n\
|
||||
- To change several places in ONE file in a single call, pass an `edits` \
|
||||
array of {old_string, new_string, replace_all?} objects — they are applied \
|
||||
in order, atomically (all or nothing): if any hunk fails to match, the file \
|
||||
is left untouched. Prefer this over many separate Edit calls when refactoring.\n\
|
||||
- Each old_string must be unique in the file (at the point it is applied). \
|
||||
If multiple matches exist, the edit fails — add surrounding context or set \
|
||||
replace_all to change every occurrence.\n\
|
||||
- Prefer Edit over Write for modifying existing files — Edit only sends the diff.\n\
|
||||
- When matching text from Read output, preserve the exact indentation (tabs/spaces)."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to modify (absolute preferred; a relative path resolves against the session working directory)"
|
||||
},
|
||||
"old_string": {
|
||||
"type": "string",
|
||||
"description": "The text to replace (single-edit mode)"
|
||||
},
|
||||
"new_string": {
|
||||
"type": "string",
|
||||
"description": "The replacement text (single-edit mode)"
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace all occurrences (default false)"
|
||||
},
|
||||
"edits": {
|
||||
"type": "array",
|
||||
"description": "Multi-edit mode: a list of edits applied in order to the same file, atomically (all-or-nothing). Use instead of old_string/new_string for multiple changes in one call.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"old_string": { "type": "string", "description": "The text to replace" },
|
||||
"new_string": { "type": "string", "description": "The replacement text" },
|
||||
"replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)" }
|
||||
},
|
||||
"required": ["old_string", "new_string"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(file_path) = input["file_path"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: file_path".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
// Resolve a relative file_path against the session working directory
|
||||
// (matching ReadTool/Grep/Glob/Bash) before any filesystem use.
|
||||
let resolved = crate::path_guard::resolve_against_cwd(file_path, self.cwd.as_deref());
|
||||
let file_path = resolved.as_str();
|
||||
|
||||
// Write-root containment (opt-in): reject edits outside the configured root.
|
||||
if let Some(msg) = crate::path_guard::ensure_within_root(file_path, self.write_root.as_deref()) {
|
||||
return ToolResult {
|
||||
content: msg,
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Accept either a multi-edit `edits` array (applied atomically in one
|
||||
// write) or the legacy single old_string/new_string triple.
|
||||
let ops: Vec<EditOp> = if let Some(arr) = input["edits"].as_array() {
|
||||
if arr.is_empty() {
|
||||
return ToolResult {
|
||||
content: "edits array must not be empty".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
let mut ops = Vec::with_capacity(arr.len());
|
||||
for (i, e) in arr.iter().enumerate() {
|
||||
let (Some(o), Some(n)) = (e["old_string"].as_str(), e["new_string"].as_str()) else {
|
||||
return ToolResult {
|
||||
content: format!("edit #{}: missing old_string or new_string", i + 1),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
ops.push(EditOp {
|
||||
old_string: o.to_string(),
|
||||
new_string: n.to_string(),
|
||||
replace_all: e["replace_all"].as_bool().unwrap_or(false),
|
||||
});
|
||||
}
|
||||
ops
|
||||
} else {
|
||||
let Some(old_string) = input["old_string"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: old_string".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
let Some(new_string) = input["new_string"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: new_string".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
vec![EditOp {
|
||||
old_string: old_string.to_string(),
|
||||
new_string: new_string.to_string(),
|
||||
replace_all: input["replace_all"].as_bool().unwrap_or(false),
|
||||
}]
|
||||
};
|
||||
|
||||
let path = Path::new(file_path);
|
||||
|
||||
// Cache guard: "must Read first" + staleness detection.
|
||||
if let Some(cache_arc) = &self.file_cache
|
||||
&& let Ok(mut cache) = cache_arc.write()
|
||||
{
|
||||
let cached = cache.get(path);
|
||||
if cached.is_none() {
|
||||
return ToolResult {
|
||||
content: format!(
|
||||
"You must Read {} before editing. Use the Read tool first \
|
||||
so the file content is loaded into context.",
|
||||
file_path
|
||||
),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
// Staleness check: compare cached mtime with current disk mtime.
|
||||
let cached_mtime = cached.map(|s| s.mtime_ms);
|
||||
let disk_mtime = file_mtime_ms(path);
|
||||
if let (Some(cached_mt), Some(disk_mt)) = (cached_mtime, disk_mtime)
|
||||
&& cached_mt != disk_mt
|
||||
{
|
||||
return ToolResult {
|
||||
content: format!(
|
||||
"File {} has been modified externally since last read. \
|
||||
Read the file again to see the current content before editing.",
|
||||
file_path
|
||||
),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(file_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
content: format!("Failed to read file {}: {}", file_path, e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let (new_content, total) = match apply_edits(&content, &ops) {
|
||||
Ok(r) => r,
|
||||
Err(msg) => {
|
||||
return ToolResult {
|
||||
content: msg,
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = crate::atomic_write(file_path, &new_content) {
|
||||
return ToolResult {
|
||||
content: format!("Failed to write file: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Post-write cache update: refresh mtime and content.
|
||||
if let Some(cache_arc) = &self.file_cache {
|
||||
update_cache_after_write(cache_arc, path, &new_content);
|
||||
}
|
||||
|
||||
ToolResult {
|
||||
content: if ops.len() > 1 {
|
||||
format!(
|
||||
"Edited {}: {} replacement(s) across {} edits",
|
||||
file_path,
|
||||
total,
|
||||
ops.len()
|
||||
)
|
||||
} else {
|
||||
format!("Edited {}: replaced {} occurrence(s)", file_path, total)
|
||||
},
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn max_result_size(&self) -> usize {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Edit
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let path = input
|
||||
.get("file_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
format!("Edit {}", path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::file_cache::update_cache_after_write;
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
/// Simulate a Read by inserting a cache entry for the given file path.
|
||||
fn simulate_read(cache: &Arc<RwLock<FileStateCache>>, path: &Path) {
|
||||
let content = std::fs::read_to_string(path).unwrap_or_default();
|
||||
update_cache_after_write(cache, path, &content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_resolves_relative_path_against_cwd() {
|
||||
// A relative file_path must resolve against the injected workspace cwd,
|
||||
// not the process cwd. Use no cache so the must-read guard is off.
|
||||
let workspace = tempdir().unwrap();
|
||||
let rel = "__nomi_reltest_edit__.txt";
|
||||
std::fs::write(workspace.path().join(rel), "alpha").unwrap();
|
||||
let tool = EditTool::new(None).with_cwd(Some(workspace.path().to_path_buf()));
|
||||
let result = tool
|
||||
.execute(json!({ "file_path": rel, "old_string": "alpha", "new_string": "beta" }))
|
||||
.await;
|
||||
assert!(!result.is_error, "relative edit should succeed: {}", result.content);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.path().join(rel)).unwrap(),
|
||||
"beta",
|
||||
"the relative edit must have applied to the workspace file"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_edit_applies_all_hunks_in_one_call() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("m.txt");
|
||||
std::fs::write(&file_path, "alpha beta gamma").unwrap();
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"edits": [
|
||||
{ "old_string": "alpha", "new_string": "A" },
|
||||
{ "old_string": "gamma", "new_string": "G" }
|
||||
]
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "A beta G");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_edit_failing_hunk_leaves_file_untouched() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("m.txt");
|
||||
std::fs::write(&file_path, "alpha beta").unwrap();
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"edits": [
|
||||
{ "old_string": "alpha", "new_string": "A" },
|
||||
{ "old_string": "NOPE", "new_string": "x" }
|
||||
]
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(result.is_error, "a failing hunk must fail the whole edit");
|
||||
// Atomic: the first (matching) hunk must NOT have been written.
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "alpha beta");
|
||||
}
|
||||
|
||||
// -- Legacy tests (no cache) --
|
||||
|
||||
#[test]
|
||||
fn apply_edits_applies_multiple_hunks_in_order() {
|
||||
use super::{EditOp, apply_edits};
|
||||
let ops = vec![
|
||||
EditOp { old_string: "foo".into(), new_string: "bar".into(), replace_all: false },
|
||||
EditOp { old_string: "bar".into(), new_string: "baz".into(), replace_all: false },
|
||||
];
|
||||
// Sequential: edit 1 foo->bar => "bar X"; edit 2 sees "bar" and -> baz => "baz X".
|
||||
let (out, n) = apply_edits("foo X", &ops).unwrap();
|
||||
assert_eq!(out, "baz X");
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edits_aborts_on_missing_hunk_identifying_which() {
|
||||
use super::{EditOp, apply_edits};
|
||||
let ops = vec![
|
||||
EditOp { old_string: "foo".into(), new_string: "bar".into(), replace_all: false },
|
||||
EditOp { old_string: "NOPE".into(), new_string: "x".into(), replace_all: false },
|
||||
];
|
||||
let err = apply_edits("foo", &ops).unwrap_err();
|
||||
assert!(err.contains("not found"));
|
||||
assert!(err.contains("edit #2"), "must identify the failing hunk: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edits_replace_all_counts_all_occurrences() {
|
||||
use super::{EditOp, apply_edits};
|
||||
let ops = vec![EditOp { old_string: "a".into(), new_string: "b".into(), replace_all: true }];
|
||||
let (out, n) = apply_edits("a a a", &ops).unwrap();
|
||||
assert_eq!(out, "b b b");
|
||||
assert_eq!(n, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edits_single_hunk_messages_unprefixed() {
|
||||
use super::{EditOp, apply_edits};
|
||||
// A single edit keeps the legacy unprefixed error message (back-compat).
|
||||
let ops = vec![EditOp { old_string: "x".into(), new_string: "y".into(), replace_all: false }];
|
||||
let err = apply_edits("no match here", &ops).unwrap_err();
|
||||
assert_eq!(err, "old_string not found in file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_creates_and_replaces_without_leftover_temp() {
|
||||
let dir = tempdir().unwrap();
|
||||
let p = dir.path().join("f.txt");
|
||||
let ps = p.to_str().unwrap();
|
||||
|
||||
crate::atomic_write(ps, "hello").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello");
|
||||
|
||||
crate::atomic_write(ps, "world").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "world");
|
||||
|
||||
// The temp file must be renamed onto the target, never left behind.
|
||||
let leftover = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| e.file_name().to_string_lossy().contains(".tmp."));
|
||||
assert!(!leftover, "atomic_write must rename the temp file away");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_replace_block() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "hello world").unwrap();
|
||||
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "goodbye world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_old_string_not_found() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "hello world").unwrap();
|
||||
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "nonexistent",
|
||||
"new_string": "replacement"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result.content.contains("not found"),
|
||||
"expected 'not found' in error message, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_preserves_surrounding() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "aaa\nbbb\nccc\n").unwrap();
|
||||
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "bbb",
|
||||
"new_string": "XXX"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "aaa\nXXX\nccc\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_nonexistent_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("does_not_exist.txt");
|
||||
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "anything",
|
||||
"new_string": "replacement"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result.content.contains("Failed to read file"),
|
||||
"expected read failure message, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
// -- Cache guard tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_without_read_returns_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("unread.txt");
|
||||
std::fs::write(&file_path, "hello").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = EditTool::new(Some(cache));
|
||||
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "bye"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result.content.contains("must Read"),
|
||||
"expected 'must Read' in error: {}",
|
||||
result.content
|
||||
);
|
||||
// File must be unchanged.
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_after_read_succeeds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("read_then_edit.txt");
|
||||
std::fs::write(&file_path, "hello world").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
simulate_read(&cache, &file_path);
|
||||
|
||||
let tool = EditTool::new(Some(cache));
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file_path).unwrap(),
|
||||
"goodbye world"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_detects_external_modification() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("stale.txt");
|
||||
std::fs::write(&file_path, "original").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
simulate_read(&cache, &file_path);
|
||||
|
||||
// External modification: change file after caching.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file_path, "externally changed").unwrap();
|
||||
|
||||
let tool = EditTool::new(Some(cache));
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "original",
|
||||
"new_string": "new"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result.content.contains("modified externally"),
|
||||
"expected staleness error: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_then_edit_succeeds_via_cache_update() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("double_edit.txt");
|
||||
std::fs::write(&file_path, "aaa bbb ccc").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
simulate_read(&cache, &file_path);
|
||||
|
||||
let tool = EditTool::new(Some(cache));
|
||||
|
||||
// First edit.
|
||||
let input1 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "aaa",
|
||||
"new_string": "AAA"
|
||||
});
|
||||
let r1 = tool.execute(input1).await;
|
||||
assert!(!r1.is_error, "first edit failed: {}", r1.content);
|
||||
|
||||
// Second edit should succeed because first edit updated the cache.
|
||||
let input2 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "bbb",
|
||||
"new_string": "BBB"
|
||||
});
|
||||
let r2 = tool.execute(input2).await;
|
||||
assert!(!r2.is_error, "second edit failed: {}", r2.content);
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "AAA BBB ccc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_cache_edit_bypasses_guard() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("nocache.txt");
|
||||
std::fs::write(&file_path, "hello").unwrap();
|
||||
|
||||
let tool = EditTool::new(None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "bye"
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"expected success without cache: {}",
|
||||
result.content
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "bye");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_all_updates_cache() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("replaceall.txt");
|
||||
std::fs::write(&file_path, "a-a-a").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
simulate_read(&cache, &file_path);
|
||||
|
||||
let tool = EditTool::new(Some(cache.clone()));
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "a",
|
||||
"new_string": "b",
|
||||
"replace_all": true
|
||||
});
|
||||
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "replace_all failed: {}", result.content);
|
||||
|
||||
// Verify cache was updated: mtime should match current disk mtime.
|
||||
let disk_mtime = file_mtime_ms(&file_path).unwrap();
|
||||
let mut c = cache.write().unwrap();
|
||||
let cached = c.get(&file_path).expect("file should be in cache");
|
||||
assert_eq!(cached.mtime_ms, disk_mtime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `exec_command`: start a long-lived command in a PTY and return either its
|
||||
//! output (if it exits within `yield_time_ms`) or a `session_id` the model can
|
||||
//! drive with `write_stdin`. Lets the model run REPLs (python/node), TUIs, and
|
||||
//! interactive installers.
|
||||
//!
|
||||
//! Shares an `Arc<ProcessStore>` with `WriteStdinTool` (constructed once in
|
||||
//! bootstrap, cloned into both) — the same stateful-tool pattern as `SpawnTool`
|
||||
//! / `BrowserTool`. No `Tool` trait change.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_config::shell::shell_info;
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::output_truncation::{TruncationBudget, truncate_middle};
|
||||
use crate::process_store::{ExecSession, ProcessStore, collect_until_deadline};
|
||||
use crate::pty::{Pty, PtyParams};
|
||||
|
||||
const DEFAULT_YIELD_MS: u64 = 10_000;
|
||||
const MIN_YIELD_MS: u64 = 250;
|
||||
const MAX_YIELD_MS: u64 = 30_000;
|
||||
/// Output byte budget per call, head/tail elided via the shared truncator.
|
||||
const OUTPUT_CAP_BYTES: usize = 128 * 1024;
|
||||
|
||||
pub struct ExecCommandTool {
|
||||
store: Arc<ProcessStore>,
|
||||
default_cwd: PathBuf,
|
||||
/// Shell program + flag used to run `cmd` (e.g. `sh -c`), from platform.
|
||||
shell_program: String,
|
||||
shell_flag: String,
|
||||
}
|
||||
|
||||
impl ExecCommandTool {
|
||||
pub fn new(store: Arc<ProcessStore>, cwd: PathBuf) -> Self {
|
||||
let info = shell_info();
|
||||
Self {
|
||||
store,
|
||||
default_cwd: cwd,
|
||||
shell_program: info.program.to_string(),
|
||||
shell_flag: info.flag.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ExecCommandTool {
|
||||
fn name(&self) -> &str {
|
||||
"exec_command"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Runs a command in a PTY, returning its output or a session_id for ongoing interaction.\n\n\
|
||||
Use this for long-lived, interactive processes: REPLs (python, node), TUIs, and \
|
||||
interactive installers — things the one-shot Bash tool cannot drive.\n\n\
|
||||
- If the process exits within yield_time_ms, the result reports its exit_code and NO \
|
||||
session_id.\n\
|
||||
- If it is still running, the result includes a session_id — feed further input with \
|
||||
write_stdin (chars=\"\" polls for more output without writing).\n\n\
|
||||
IMPORTANT (TUI submit): when driving an interactive program, send the Enter/return key \
|
||||
(\"\\r\") as its OWN separate write_stdin call, after writing the line of text. Sending \
|
||||
text and the carriage return in a single burst can be swallowed by a TUI's paste-burst \
|
||||
detection, leaving the command unsubmitted."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute."
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": "Working directory. Defaults to the session cwd."
|
||||
},
|
||||
"tty": {
|
||||
"type": "boolean",
|
||||
"description": "Allocate a fuller PTY window for TUI programs. Defaults to false."
|
||||
},
|
||||
"yield_time_ms": {
|
||||
"type": "number",
|
||||
"description": "Milliseconds to wait for output before yielding. Default 10000, range 250-30000."
|
||||
}
|
||||
},
|
||||
"required": ["cmd"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false // PTY sessions are serialized.
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
// Same trust level as Bash: it can run arbitrary commands, so it goes
|
||||
// through the same approval gating, not Info.
|
||||
ToolCategory::Exec
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let c = input.get("cmd").and_then(|v| v.as_str()).unwrap_or("");
|
||||
format!("exec_command: {}", crate::truncate_utf8(c, 80))
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let cmd = match input.get("cmd").and_then(|v| v.as_str()) {
|
||||
Some(c) if !c.is_empty() => c.to_string(),
|
||||
_ => return ToolResult::error("exec_command: missing required parameter `cmd`"),
|
||||
};
|
||||
let cwd = input
|
||||
.get("workdir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| self.default_cwd.to_string_lossy().into_owned());
|
||||
let tty = input.get("tty").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let yield_ms = input
|
||||
.get("yield_time_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_YIELD_MS)
|
||||
.clamp(MIN_YIELD_MS, MAX_YIELD_MS);
|
||||
|
||||
// Run through the platform login shell, mirroring nomi's Bash tool.
|
||||
let params = PtyParams {
|
||||
program: self.shell_program.clone(),
|
||||
args: vec![self.shell_flag.clone(), cmd.clone()],
|
||||
cwd,
|
||||
env: std::env::vars().collect(),
|
||||
cols: if tty { 120 } else { 80 },
|
||||
rows: if tty { 30 } else { 24 },
|
||||
};
|
||||
let pty = match Pty::spawn(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return ToolResult::error(format!("exec_command: spawn failed: {e}")),
|
||||
};
|
||||
// Subscribe immediately after spawn so we don't miss the first output.
|
||||
let rx = pty.subscribe();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(yield_ms);
|
||||
let collected = collect_until_deadline(&pty, rx, deadline).await;
|
||||
let text = truncate_middle(
|
||||
&String::from_utf8_lossy(&collected),
|
||||
TruncationBudget::Bytes(OUTPUT_CAP_BYTES),
|
||||
);
|
||||
|
||||
if pty.has_exited() {
|
||||
let code = pty.exit_code().unwrap_or(-1);
|
||||
ToolResult::text(format!("(process exited, exit_code={code})\n{text}"))
|
||||
} else {
|
||||
let (id, pruned) = self
|
||||
.store
|
||||
.insert(ExecSession {
|
||||
id: 0,
|
||||
pty: pty.clone(),
|
||||
command: cmd,
|
||||
tty,
|
||||
last_used: tokio::time::Instant::now(),
|
||||
})
|
||||
.await;
|
||||
// Kill the evicted session's process OUTSIDE the store lock.
|
||||
if let Some(victim) = pruned {
|
||||
victim.kill();
|
||||
}
|
||||
ToolResult::text(format!(
|
||||
"session_id={id}\n(process still running — use write_stdin to continue)\n{text}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_session_id(content: &str) -> Option<u64> {
|
||||
content
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("session_id="))
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn immediate_exit_reports_exit_code_no_session() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let tool = ExecCommandTool::new(store, std::env::current_dir().unwrap());
|
||||
let r = tool
|
||||
.execute(serde_json::json!({"cmd": "echo done_marker", "yield_time_ms": 3000}))
|
||||
.await;
|
||||
assert!(!r.is_error, "unexpected error: {}", r.content);
|
||||
assert!(r.content.contains("exit_code=0"), "got: {}", r.content);
|
||||
assert!(r.content.contains("done_marker"), "got: {}", r.content);
|
||||
assert!(parse_session_id(&r.content).is_none(), "should not get a session_id: {}", r.content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_lived_returns_session_id() {
|
||||
use crate::test_support::pty_test_helper_shell_cmd;
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let tool = ExecCommandTool::new(store.clone(), std::env::current_dir().unwrap());
|
||||
// The helper's `echo-stdin` blocks on stdin → stays alive past the short
|
||||
// yield (cross-platform stand-in for `cat`).
|
||||
let r = tool
|
||||
.execute(serde_json::json!({
|
||||
"cmd": pty_test_helper_shell_cmd("echo-stdin"),
|
||||
"yield_time_ms": 400
|
||||
}))
|
||||
.await;
|
||||
assert!(!r.is_error, "unexpected error: {}", r.content);
|
||||
let sid = parse_session_id(&r.content)
|
||||
.expect("echo-stdin should stay alive and return a session_id");
|
||||
assert!(store.contains(sid).await, "session should be in the store");
|
||||
// Clean up.
|
||||
store.terminate_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_cmd_is_error() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let tool = ExecCommandTool::new(store, std::env::current_dir().unwrap());
|
||||
let r = tool.execute(serde_json::json!({})).await;
|
||||
assert!(r.is_error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use lru::LruCache;
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_types::file_state::FileState;
|
||||
|
||||
/// LRU cache for file states seen by the model.
|
||||
///
|
||||
/// Provides dual eviction: entry-count limit (via LRU) and byte-size limit
|
||||
/// (manually tracked). All path keys are normalized before access so that
|
||||
/// `"/a/../b"` and `"/b"` map to the same cache slot.
|
||||
///
|
||||
/// Thread safety: wrap in `Arc<std::sync::RwLock<FileStateCache>>` when
|
||||
/// sharing across tools. Cache operations are brief (hash lookup + insert),
|
||||
/// so `std::sync::RwLock` is preferred over `tokio::sync::RwLock`.
|
||||
pub struct FileStateCache {
|
||||
entries: LruCache<PathBuf, FileState>,
|
||||
max_size_bytes: usize,
|
||||
current_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl FileStateCache {
|
||||
/// Create a new cache from configuration.
|
||||
///
|
||||
/// If `max_entries` is 0, defaults to 100.
|
||||
pub fn new(config: &FileCacheConfig) -> Self {
|
||||
let cap = NonZeroUsize::new(config.max_entries)
|
||||
.unwrap_or(NonZeroUsize::new(100).expect("100 is non-zero"));
|
||||
Self {
|
||||
entries: LruCache::new(cap),
|
||||
max_size_bytes: config.max_size_bytes,
|
||||
current_size_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a file state, promoting it to most-recently-used.
|
||||
pub fn get(&mut self, path: &Path) -> Option<&FileState> {
|
||||
let normalized = normalize_path(path);
|
||||
self.entries.get(&normalized)
|
||||
}
|
||||
|
||||
/// Insert or update a file state entry.
|
||||
///
|
||||
/// Evicts least-recently-used entries when the byte-size limit or
|
||||
/// entry-count limit would be exceeded.
|
||||
pub fn insert(&mut self, path: PathBuf, state: FileState) {
|
||||
let normalized = normalize_path(&path);
|
||||
let new_size = state.content_bytes();
|
||||
|
||||
// Remove existing entry for this key first (simplifies size accounting).
|
||||
if let Some(old) = self.entries.pop(&normalized) {
|
||||
self.current_size_bytes = self.current_size_bytes.saturating_sub(old.content_bytes());
|
||||
}
|
||||
|
||||
// Evict LRU entries until byte-size budget is available.
|
||||
while self.current_size_bytes + new_size > self.max_size_bytes && !self.entries.is_empty() {
|
||||
if let Some((_k, v)) = self.entries.pop_lru() {
|
||||
self.current_size_bytes = self.current_size_bytes.saturating_sub(v.content_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// push() returns evicted (key, value) if entry-count capacity is reached.
|
||||
if let Some((_evicted_key, evicted_val)) = self.entries.push(normalized, state) {
|
||||
self.current_size_bytes = self
|
||||
.current_size_bytes
|
||||
.saturating_sub(evicted_val.content_bytes());
|
||||
}
|
||||
self.current_size_bytes += new_size;
|
||||
}
|
||||
|
||||
/// Remove a specific entry by path.
|
||||
pub fn remove(&mut self, path: &Path) -> Option<FileState> {
|
||||
let normalized = normalize_path(path);
|
||||
let removed = self.entries.pop(&normalized);
|
||||
if let Some(ref v) = removed {
|
||||
self.current_size_bytes = self.current_size_bytes.saturating_sub(v.content_bytes());
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove all entries.
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.current_size_bytes = 0;
|
||||
}
|
||||
|
||||
/// Number of cached entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the cache is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Current total byte size of all cached content.
|
||||
pub fn current_size_bytes(&self) -> usize {
|
||||
self.current_size_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the cache after a successful file write (Edit or Write).
|
||||
///
|
||||
/// Reads the new mtime from disk and stores line-numbered content.
|
||||
/// This is the single point for post-write cache updates, eliminating
|
||||
/// duplication between EditTool and WriteTool.
|
||||
pub fn update_cache_after_write(
|
||||
cache_arc: &Arc<std::sync::RwLock<FileStateCache>>,
|
||||
path: &Path,
|
||||
content: &str,
|
||||
) {
|
||||
let Ok(mut cache) = cache_arc.write() else {
|
||||
return;
|
||||
};
|
||||
let Some(new_mtime) = file_mtime_ms(path) else {
|
||||
return;
|
||||
};
|
||||
let numbered: Vec<String> = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>6}\t{}", i + 1, line))
|
||||
.collect();
|
||||
cache.insert(
|
||||
path.to_path_buf(),
|
||||
FileState {
|
||||
content: numbered.join("\n"),
|
||||
mtime_ms: new_mtime,
|
||||
offset: None,
|
||||
limit: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get file modification time as milliseconds since UNIX epoch.
|
||||
///
|
||||
/// Returns `None` if the file does not exist or metadata is unavailable.
|
||||
pub fn file_mtime_ms(path: &Path) -> Option<u64> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?;
|
||||
let duration = modified.duration_since(UNIX_EPOCH).ok()?;
|
||||
Some(duration.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// Normalize a path by resolving `.` and `..` components without filesystem access.
|
||||
///
|
||||
/// Unlike `std::fs::canonicalize`, this does not require the path to exist on disk,
|
||||
/// which is important because cache lookups can happen before the file is created.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `/a/../b/file` -> `/b/file`
|
||||
/// - `a/./b/../c` -> `a/c`
|
||||
/// - `/../b` -> `/b` (can't go above root)
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
let mut components: Vec<Component> = Vec::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => match components.last() {
|
||||
Some(Component::Normal(_)) => {
|
||||
components.pop();
|
||||
}
|
||||
Some(Component::RootDir) => {
|
||||
// Can't go above filesystem root; ignore the `..`
|
||||
}
|
||||
_ => {
|
||||
// Preserve leading `..` in relative paths (e.g. `../../foo`)
|
||||
components.push(component);
|
||||
}
|
||||
},
|
||||
Component::CurDir => {} // skip `.`
|
||||
other => components.push(other),
|
||||
}
|
||||
}
|
||||
let mut result = PathBuf::new();
|
||||
for c in &components {
|
||||
result.push(c);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config(max_entries: usize, max_size_bytes: usize) -> FileCacheConfig {
|
||||
FileCacheConfig {
|
||||
max_entries,
|
||||
max_size_bytes,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state(content: &str, mtime_ms: u64) -> FileState {
|
||||
FileState {
|
||||
content: content.to_string(),
|
||||
mtime_ms,
|
||||
offset: None,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
// -- normalize_path tests --
|
||||
|
||||
#[test]
|
||||
fn normalize_resolves_parent_dir() {
|
||||
let result = normalize_path(Path::new("/a/../b/file"));
|
||||
assert_eq!(result, PathBuf::from("/b/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_resolves_cur_dir() {
|
||||
let result = normalize_path(Path::new("/a/./b/file"));
|
||||
assert_eq!(result, PathBuf::from("/a/b/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_above_root_is_clamped() {
|
||||
let result = normalize_path(Path::new("/../b"));
|
||||
assert_eq!(result, PathBuf::from("/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_leading_parent_in_relative() {
|
||||
let result = normalize_path(Path::new("../../foo"));
|
||||
assert_eq!(result, PathBuf::from("../../foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_mixed() {
|
||||
let result = normalize_path(Path::new("a/./b/../c"));
|
||||
assert_eq!(result, PathBuf::from("a/c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_absolute_identity() {
|
||||
let result = normalize_path(Path::new("/usr/local/bin"));
|
||||
assert_eq!(result, PathBuf::from("/usr/local/bin"));
|
||||
}
|
||||
|
||||
// -- FileStateCache core tests --
|
||||
|
||||
#[test]
|
||||
fn insert_and_get() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
let path = PathBuf::from("/tmp/test.rs");
|
||||
let state = make_state("hello", 1000);
|
||||
|
||||
cache.insert(path.clone(), state);
|
||||
let got = cache.get(&path).unwrap();
|
||||
assert_eq!(got.content, "hello");
|
||||
assert_eq!(got.mtime_ms, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_nonexistent_returns_none() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
assert!(cache.get(Path::new("/does/not/exist")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lru_eviction_by_count() {
|
||||
let config = make_config(3, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("a", 1));
|
||||
cache.insert(PathBuf::from("/b"), make_state("b", 2));
|
||||
cache.insert(PathBuf::from("/c"), make_state("c", 3));
|
||||
// Cache is at capacity (3). Inserting a 4th evicts the LRU (/a).
|
||||
cache.insert(PathBuf::from("/d"), make_state("d", 4));
|
||||
|
||||
assert!(cache.get(Path::new("/a")).is_none(), "/a should be evicted");
|
||||
assert!(cache.get(Path::new("/b")).is_some());
|
||||
assert!(cache.get(Path::new("/c")).is_some());
|
||||
assert!(cache.get(Path::new("/d")).is_some());
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_normalization_hits_same_slot() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a/../b/file"), make_state("v1", 100));
|
||||
let got = cache.get(Path::new("/b/file")).unwrap();
|
||||
assert_eq!(got.content, "v1");
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_all() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("a", 1));
|
||||
cache.insert(PathBuf::from("/b"), make_state("b", 2));
|
||||
assert_eq!(cache.len(), 2);
|
||||
|
||||
cache.clear();
|
||||
assert_eq!(cache.len(), 0);
|
||||
assert!(cache.is_empty());
|
||||
assert_eq!(cache.current_size_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_entry() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("a-content", 1));
|
||||
let removed = cache.remove(Path::new("/a"));
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(removed.unwrap().content, "a-content");
|
||||
assert!(cache.get(Path::new("/a")).is_none());
|
||||
assert_eq!(cache.len(), 0);
|
||||
assert_eq!(cache.current_size_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_size_eviction() {
|
||||
// max_size_bytes = 10, each entry ~5 bytes ("aaaaa").
|
||||
let config = make_config(100, 10);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("aaaaa", 1)); // 5 bytes
|
||||
cache.insert(PathBuf::from("/b"), make_state("bbbbb", 2)); // 5 bytes -> total 10
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert_eq!(cache.current_size_bytes(), 10);
|
||||
|
||||
// Inserting /c (5 bytes) would exceed 10 -> evicts /a (LRU)
|
||||
cache.insert(PathBuf::from("/c"), make_state("ccccc", 3));
|
||||
assert!(cache.get(Path::new("/a")).is_none(), "/a should be evicted");
|
||||
assert!(cache.get(Path::new("/b")).is_some());
|
||||
assert!(cache.get(Path::new("/c")).is_some());
|
||||
assert_eq!(cache.current_size_bytes(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_same_key() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("v1", 100));
|
||||
cache.insert(PathBuf::from("/a"), make_state("v2-longer", 200));
|
||||
|
||||
let got = cache.get(Path::new("/a")).unwrap();
|
||||
assert_eq!(got.content, "v2-longer");
|
||||
assert_eq!(got.mtime_ms, 200);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert_eq!(cache.current_size_bytes(), "v2-longer".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_accounting_after_remove() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("hello", 1)); // 5 bytes
|
||||
cache.insert(PathBuf::from("/b"), make_state("world!", 2)); // 6 bytes
|
||||
assert_eq!(cache.current_size_bytes(), 11);
|
||||
|
||||
cache.remove(Path::new("/a"));
|
||||
assert_eq!(cache.current_size_bytes(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_entries_defaults_to_100() {
|
||||
let config = make_config(0, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
// Should not panic; defaults to capacity 100.
|
||||
for i in 0..100 {
|
||||
cache.insert(PathBuf::from(format!("/f{}", i)), make_state("x", i as u64));
|
||||
}
|
||||
assert_eq!(cache.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_promotes_entry_preventing_eviction() {
|
||||
let config = make_config(3, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("a", 1));
|
||||
cache.insert(PathBuf::from("/b"), make_state("b", 2));
|
||||
cache.insert(PathBuf::from("/c"), make_state("c", 3));
|
||||
|
||||
// Access /a to promote it; now /b is the LRU.
|
||||
cache.get(Path::new("/a"));
|
||||
|
||||
// Insert /d -> evicts /b (LRU), not /a.
|
||||
cache.insert(PathBuf::from("/d"), make_state("d", 4));
|
||||
assert!(cache.get(Path::new("/a")).is_some(), "/a should survive");
|
||||
assert!(cache.get(Path::new("/b")).is_none(), "/b should be evicted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_cached() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/empty"), make_state("", 1));
|
||||
assert!(cache.get(Path::new("/empty")).is_some());
|
||||
assert_eq!(cache.current_size_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_read_state_preserved() {
|
||||
let config = make_config(10, 1_000_000);
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
let state = FileState {
|
||||
content: "partial content".to_string(),
|
||||
mtime_ms: 500,
|
||||
offset: Some(10),
|
||||
limit: Some(20),
|
||||
};
|
||||
cache.insert(PathBuf::from("/file"), state);
|
||||
let got = cache.get(Path::new("/file")).unwrap();
|
||||
assert_eq!(got.offset, Some(10));
|
||||
assert_eq!(got.limit, Some(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
||||
pub struct GlobTool {
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl GlobTool {
|
||||
pub fn new(cwd: PathBuf) -> Self {
|
||||
Self { cwd }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GlobTool {
|
||||
fn name(&self) -> &str {
|
||||
"Glob"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Fast file pattern matching tool that works with any codebase size.\n\n\
|
||||
- Supports glob patterns like \"**/*.rs\" or \"src/**/*.ts\".\n\
|
||||
- Returns matching file paths sorted by modification time (newest first).\n\
|
||||
- Returns at most 100 results. Only returns files, not directories.\n\
|
||||
- The path parameter defaults to the current working directory.\n\
|
||||
- Use this tool when you need to find files by name or extension patterns."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern, e.g. \"**/*.rs\""
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Root directory (default: cwd)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(pattern) = input["pattern"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: pattern".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
let root = input["path"].as_str().unwrap_or(".");
|
||||
let root_path = if Path::new(root).is_relative() {
|
||||
self.cwd.join(root)
|
||||
} else {
|
||||
PathBuf::from(root)
|
||||
};
|
||||
|
||||
tracing::debug!(cwd = %self.cwd.display(), resolved_root = %root_path.display(), pattern = %pattern, "GlobTool scanning");
|
||||
|
||||
// Build full glob pattern
|
||||
let full_pattern = if pattern.starts_with('/') {
|
||||
pattern.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_path.display(), pattern)
|
||||
};
|
||||
|
||||
let entries = match glob::glob(&full_pattern) {
|
||||
Ok(paths) => paths,
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
content: format!("Invalid glob pattern: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut files: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
let mut total_matched = 0usize;
|
||||
|
||||
for entry in entries {
|
||||
let Ok(path) = entry else {
|
||||
continue;
|
||||
};
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
total_matched += 1;
|
||||
if files.len() >= MAX_RESULTS {
|
||||
// Keep counting the true total so truncation is reported
|
||||
// accurately, but stop storing to bound memory on huge matches.
|
||||
continue;
|
||||
}
|
||||
|
||||
let mtime = path
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
|
||||
// Make path relative to root
|
||||
let display_path = path
|
||||
.strip_prefix(&root_path)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
files.push((mtime, display_path));
|
||||
}
|
||||
|
||||
// Sort by modification time, newest first
|
||||
files.sort_by_key(|f| std::cmp::Reverse(f.0));
|
||||
|
||||
if files.is_empty() {
|
||||
return ToolResult {
|
||||
content: "No files matched the pattern".to_string(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut result: Vec<String> = files.into_iter().map(|(_, path)| path).collect();
|
||||
if total_matched > MAX_RESULTS {
|
||||
result.push(format!(
|
||||
"... [showing {} of {} matching files — refine the pattern or path]",
|
||||
MAX_RESULTS, total_matched
|
||||
));
|
||||
}
|
||||
ToolResult {
|
||||
content: result.join("\n"),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
|
||||
format!("Search for {}", pattern)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use nomi_types::tool::ToolResult;
|
||||
|
||||
async fn run_glob(pattern: &str, path: &str) -> ToolResult {
|
||||
let tool = GlobTool::new(PathBuf::from(path));
|
||||
let input = json!({ "pattern": pattern, "path": path });
|
||||
tool.execute(input).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_reports_truncation_with_true_total() {
|
||||
let dir = tempdir().unwrap();
|
||||
let base = dir.path();
|
||||
let n = super::MAX_RESULTS + 5;
|
||||
for i in 0..n {
|
||||
fs::write(base.join(format!("f{i}.rs")), "x").unwrap();
|
||||
}
|
||||
let result = run_glob("*.rs", base.to_str().unwrap()).await;
|
||||
assert!(!result.is_error, "glob should succeed: {}", result.content);
|
||||
assert!(
|
||||
result.content.contains(&n.to_string()),
|
||||
"must report the true total {n}, got: {}",
|
||||
result.content
|
||||
);
|
||||
assert!(
|
||||
result.content.to_lowercase().contains("truncat")
|
||||
|| result.content.contains("showing"),
|
||||
"must announce truncation, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_matches_pattern() {
|
||||
let dir = tempdir().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
fs::write(base.join("main.rs"), "fn main() {}").unwrap();
|
||||
fs::write(base.join("lib.rs"), "pub mod lib;").unwrap();
|
||||
fs::write(base.join("notes.txt"), "some notes").unwrap();
|
||||
fs::write(base.join("readme.md"), "# Readme").unwrap();
|
||||
|
||||
let result = run_glob("*.rs", base.to_str().unwrap()).await;
|
||||
|
||||
assert!(!result.is_error, "glob should succeed");
|
||||
let lines: Vec<&str> = result.content.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "should match exactly 2 .rs files");
|
||||
for line in &lines {
|
||||
assert!(
|
||||
line.ends_with(".rs"),
|
||||
"each match should be a .rs file, got: {}",
|
||||
line
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!result.content.contains("notes.txt"),
|
||||
"should not include .txt files"
|
||||
);
|
||||
assert!(
|
||||
!result.content.contains("readme.md"),
|
||||
"should not include .md files"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_no_matches() {
|
||||
let dir = tempdir().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
fs::write(base.join("main.rs"), "fn main() {}").unwrap();
|
||||
fs::write(base.join("lib.rs"), "pub mod lib;").unwrap();
|
||||
|
||||
let result = run_glob("*.xyz", base.to_str().unwrap()).await;
|
||||
|
||||
assert!(!result.is_error, "no-match glob should not be an error");
|
||||
assert_eq!(result.content, "No files matched the pattern");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_with_limit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
for i in 0..5 {
|
||||
fs::write(
|
||||
base.join(format!("file_{}.txt", i)),
|
||||
format!("content {}", i),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = run_glob("*.txt", base.to_str().unwrap()).await;
|
||||
|
||||
assert!(!result.is_error, "glob should succeed");
|
||||
let lines: Vec<&str> = result.content.lines().collect();
|
||||
assert_eq!(lines.len(), 5, "all 5 files should be returned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glob_recursive() {
|
||||
let dir = tempdir().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
// Create nested directory structure
|
||||
let sub_a = base.join("a");
|
||||
let sub_b = base.join("a").join("b");
|
||||
fs::create_dir_all(&sub_b).unwrap();
|
||||
|
||||
fs::write(base.join("root.txt"), "root level").unwrap();
|
||||
fs::write(sub_a.join("mid.txt"), "middle level").unwrap();
|
||||
fs::write(sub_b.join("deep.txt"), "deep level").unwrap();
|
||||
// Non-matching file
|
||||
fs::write(sub_a.join("skip.rs"), "not a txt").unwrap();
|
||||
|
||||
let result = run_glob("**/*.txt", base.to_str().unwrap()).await;
|
||||
|
||||
assert!(!result.is_error, "recursive glob should succeed");
|
||||
let lines: Vec<&str> = result.content.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "should find 3 .txt files across all levels");
|
||||
assert!(
|
||||
result.content.contains("root.txt"),
|
||||
"should include root-level file"
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("mid.txt"),
|
||||
"should include mid-level file"
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("deep.txt"),
|
||||
"should include deep-level file"
|
||||
);
|
||||
assert!(
|
||||
!result.content.contains("skip.rs"),
|
||||
"should not include .rs files"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_uses_cwd_for_relative_path() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fs::write(tmp.path().join("marker.txt"), "hello").unwrap();
|
||||
|
||||
let tool = GlobTool::new(tmp.path().to_path_buf());
|
||||
let input = json!({"pattern": "marker.txt"});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(
|
||||
result.content.contains("marker.txt"),
|
||||
"should find marker.txt, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::process::Command;
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
pub struct GrepTool {
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl GrepTool {
|
||||
pub fn new(cwd: PathBuf) -> Self {
|
||||
Self { cwd }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GrepTool {
|
||||
fn name(&self) -> &str {
|
||||
"Grep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Searches file contents using regex patterns (powered by ripgrep).\n\n\
|
||||
IMPORTANT: ALWAYS use this Grep tool for content search. \
|
||||
NEVER run grep or rg as a Bash command.\n\n\
|
||||
- Supports full regex syntax (e.g., \"log.*Error\", \"fn\\\\s+\\\\w+\").\n\
|
||||
- Use the glob parameter to filter by file pattern (e.g., \"*.rs\").\n\
|
||||
- Set context_lines (e.g. 2) to include surrounding lines for each match.\n\
|
||||
- Output is capped at 250 lines; when truncated, a notice reports the \
|
||||
true total so you can narrow the pattern or glob.\n\
|
||||
- Set case_insensitive to true for case-insensitive search."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "The regex pattern to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in (default: cwd)"
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "File filter pattern, e.g. \"*.rs\""
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context to show around each match (rg -C). Default 0."
|
||||
},
|
||||
"case_insensitive": {
|
||||
"type": "boolean",
|
||||
"description": "Case insensitive search"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(pattern) = input["pattern"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: pattern".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
let raw_path = input["path"].as_str().unwrap_or(".");
|
||||
let path = if std::path::Path::new(raw_path).is_relative() {
|
||||
self.cwd.join(raw_path).to_string_lossy().into_owned()
|
||||
} else {
|
||||
raw_path.to_owned()
|
||||
};
|
||||
|
||||
tracing::debug!(cwd = %self.cwd.display(), resolved_path = %path, pattern = %pattern, "GrepTool searching");
|
||||
|
||||
let glob_pattern = input["glob"].as_str();
|
||||
let case_insensitive = input["case_insensitive"].as_bool().unwrap_or(false);
|
||||
let context_lines = input["context_lines"].as_u64().unwrap_or(0) as usize;
|
||||
|
||||
// Try ripgrep first, fallback to grep
|
||||
let result = try_ripgrep(pattern, &path, glob_pattern, case_insensitive, context_lines).await;
|
||||
|
||||
match result {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
// Fallback to grep (now also honours glob + context_lines on unix)
|
||||
try_grep(pattern, &path, glob_pattern, case_insensitive, context_lines).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn max_result_size(&self) -> usize {
|
||||
20_000
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let raw_path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
|
||||
format!("Grep '{}' in {}", pattern, raw_path)
|
||||
}
|
||||
}
|
||||
|
||||
const GREP_MAX_LINES: usize = 250;
|
||||
|
||||
/// Cap grep output to `max_lines`, appending a truncation notice with the true
|
||||
/// total when exceeded — so the model knows results were cut and can narrow the
|
||||
/// search, instead of silently losing matches.
|
||||
fn format_grep_output(stdout: &str, max_lines: usize) -> String {
|
||||
let total = stdout.lines().count();
|
||||
if total <= max_lines {
|
||||
return stdout.trim_end().to_string();
|
||||
}
|
||||
let shown: Vec<&str> = stdout.lines().take(max_lines).collect();
|
||||
format!(
|
||||
"{}\n... [truncated: showing first {} of {} matching lines — narrow your pattern or set a `glob` filter]",
|
||||
shown.join("\n"),
|
||||
max_lines,
|
||||
total
|
||||
)
|
||||
}
|
||||
|
||||
async fn try_ripgrep(
|
||||
pattern: &str,
|
||||
path: &str,
|
||||
glob_pattern: Option<&str>,
|
||||
case_insensitive: bool,
|
||||
context_lines: usize,
|
||||
) -> Result<ToolResult, std::io::Error> {
|
||||
let mut cmd = Command::new("rg");
|
||||
cmd.arg(pattern).arg(path).arg("-n");
|
||||
|
||||
if let Some(g) = glob_pattern {
|
||||
cmd.arg("--glob").arg(g);
|
||||
}
|
||||
if case_insensitive {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
if context_lines > 0 {
|
||||
cmd.arg("-C").arg(context_lines.to_string());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
|
||||
let output = cmd.output().await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if output.status.code() == Some(1) && stdout.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
content: "No matches found".to_string(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if !output.status.success() && output.status.code() != Some(1) {
|
||||
return Ok(ToolResult {
|
||||
content: format!("rg error: {}", stderr),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
content: format_grep_output(&stdout, GREP_MAX_LINES),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_grep(
|
||||
pattern: &str,
|
||||
path: &str,
|
||||
glob_pattern: Option<&str>,
|
||||
case_insensitive: bool,
|
||||
context_lines: usize,
|
||||
) -> ToolResult {
|
||||
let mut cmd = if cfg!(windows) {
|
||||
// findstr has no glob-include or context-line support; those refinements
|
||||
// are silently unavailable on the Windows fallback path.
|
||||
let mut c = Command::new("findstr");
|
||||
c.arg("/S")
|
||||
.arg("/N")
|
||||
.arg("/R")
|
||||
.arg(pattern)
|
||||
.arg(format!("{}\\*", path.trim_end_matches(['\\', '/'])));
|
||||
if case_insensitive {
|
||||
c.arg("/I");
|
||||
}
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("grep");
|
||||
c.arg("-rn").arg(pattern).arg(path);
|
||||
if case_insensitive {
|
||||
c.arg("-i");
|
||||
}
|
||||
// Honour the glob filter on the fallback path too (previously ignored,
|
||||
// so the model got matches from unintended file types).
|
||||
if let Some(g) = glob_pattern {
|
||||
c.arg(format!("--include={}", g));
|
||||
}
|
||||
if context_lines > 0 {
|
||||
c.arg("-C").arg(context_lines.to_string());
|
||||
}
|
||||
c
|
||||
};
|
||||
// CREATE_NO_WINDOW (covers the Windows `findstr` branch above).
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.is_empty() {
|
||||
ToolResult {
|
||||
content: "No matches found".to_string(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
ToolResult {
|
||||
content: format_grep_output(&stdout, GREP_MAX_LINES),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => ToolResult {
|
||||
content: format!("grep failed: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn format_grep_output_appends_truncation_notice_with_total() {
|
||||
let lines: String = (0..300).map(|i| format!("line{i}\n")).collect();
|
||||
let out = super::format_grep_output(&lines, 250);
|
||||
assert!(out.contains("truncated"), "must announce truncation: {out}");
|
||||
assert!(out.contains("300"), "must report the true total match count");
|
||||
// 250 shown lines + 1 notice line
|
||||
assert_eq!(out.lines().count(), 251);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_grep_output_short_is_unchanged() {
|
||||
let out = super::format_grep_output("a\nb\nc\n", 250);
|
||||
assert_eq!(out, "a\nb\nc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_tool_finds_pattern_in_own_source() {
|
||||
let tool = GrepTool::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
|
||||
let input = json!({
|
||||
"pattern": "GrepTool",
|
||||
"path": env!("CARGO_MANIFEST_DIR")
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "grep failed: {}", result.content);
|
||||
assert!(result.content.contains("GrepTool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_uses_cwd_for_relative_path() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("searchable.txt"), "unique_grep_marker_xyz").unwrap();
|
||||
|
||||
let tool = GrepTool::new(tmp.path().to_path_buf());
|
||||
let input = json!({"pattern": "unique_grep_marker_xyz", "path": "."});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(
|
||||
result.content.contains("unique_grep_marker_xyz"),
|
||||
"should find pattern, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
pub mod bash;
|
||||
pub mod apply_patch;
|
||||
pub mod edit;
|
||||
pub mod exec_command;
|
||||
pub mod file_cache;
|
||||
pub mod glob;
|
||||
pub mod grep;
|
||||
pub mod lsp;
|
||||
pub mod output_truncation;
|
||||
pub mod path_guard;
|
||||
pub mod persistent_shell;
|
||||
pub mod process_store;
|
||||
pub mod pty;
|
||||
pub mod read;
|
||||
pub mod registry;
|
||||
pub mod sandbox;
|
||||
pub mod tool_search;
|
||||
pub mod update_plan;
|
||||
pub mod write;
|
||||
pub mod write_stdin;
|
||||
pub mod worktree;
|
||||
|
||||
/// Shared test-only helpers (path to the cross-platform `pty_test_helper` bin).
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
|
||||
|
||||
pub use output_truncation::{approx_token_count, truncate_middle, TruncationBudget};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use nomi_config::hooks::HooksConfig;
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::skill_types::ContextModifier;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
/// Truncate a string to at most `max_bytes`, snapping to a char boundary.
|
||||
pub fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
/// Write `content` to `file_path` atomically: write to a uniquely-named temp
|
||||
/// file in the same directory, then rename it over the target. Rename is atomic
|
||||
/// on the same filesystem, so a crash or a concurrent reader never observes a
|
||||
/// half-written file. Falls back to a direct write only if the rename fails
|
||||
/// (e.g. cross-device). Shared by the Edit and Write tools so both get the same
|
||||
/// crash-safety guarantee.
|
||||
pub(crate) fn atomic_write(file_path: &str, content: &str) -> std::io::Result<()> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp_path = format!("{}.tmp.{}.{}", file_path, std::process::id(), seq);
|
||||
|
||||
if let Err(e) = std::fs::write(&tmp_path, content) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(e);
|
||||
}
|
||||
if std::fs::rename(&tmp_path, file_path).is_err() {
|
||||
// Cross-device rename (temp and target on different filesystems) cannot
|
||||
// be atomic; clean up the temp and fall back to a direct write.
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
std::fs::write(file_path, content)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tool that the agent can invoke
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Tool name (must match API schema)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Human-readable description for the LLM
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// JSON Schema for input parameters
|
||||
fn input_schema(&self) -> JsonSchema;
|
||||
|
||||
/// Whether this tool is safe to run concurrently
|
||||
fn is_concurrency_safe(&self, input: &Value) -> bool;
|
||||
|
||||
/// Execute the tool
|
||||
async fn execute(&self, input: Value) -> ToolResult;
|
||||
|
||||
/// Return an optional context modifier based on the tool input.
|
||||
/// Called after execute() to collect any engine-level overrides.
|
||||
/// Only SkillTool overrides this; all other tools return None.
|
||||
fn context_modifier_for(&self, _input: &Value) -> Option<ContextModifier> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return any hooks declared in the skill's frontmatter for dynamic registration.
|
||||
/// Called after a successful execute() so the orchestration layer can merge
|
||||
/// the returned hooks into the active HookEngine.
|
||||
/// Only SkillTool overrides this; all other tools return None.
|
||||
fn skill_hooks_for(&self, _input: &Value) -> Option<HooksConfig> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Max result size in chars before truncation
|
||||
fn max_result_size(&self) -> usize {
|
||||
50_000
|
||||
}
|
||||
|
||||
/// Tool category for protocol classification
|
||||
fn category(&self) -> ToolCategory;
|
||||
|
||||
/// Category for a specific invocation. Lets multi-action tools (e.g.
|
||||
/// Computer/Browser) report read-only actions as Info so approval
|
||||
/// gating can distinguish them from mutating actions.
|
||||
fn category_for(&self, _input: &Value) -> ToolCategory {
|
||||
self.category()
|
||||
}
|
||||
|
||||
/// Whether this tool's schema should be deferred (sent as name-only stub).
|
||||
/// Override to `true` for tools with large schemas or infrequent use.
|
||||
fn is_deferred(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Human-readable description of what the tool will do with the given input
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
format!(
|
||||
"{}: {}",
|
||||
self.name(),
|
||||
serde_json::to_string(input).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_ascii_within_limit() {
|
||||
assert_eq!(truncate_utf8("hello", 80), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_ascii_at_boundary() {
|
||||
assert_eq!(truncate_utf8("abcde", 3), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_multibyte_snaps_back() {
|
||||
// '些' is 3 bytes (E4 BA 9B) starting at index 79 would span 79..82
|
||||
let s = "# 用 script 模拟 TTY 交互来添加 DeepSeek 提供商\n# 首先看看有哪些";
|
||||
let result = truncate_utf8(s, 80);
|
||||
assert!(result.len() <= 80);
|
||||
assert!(result.is_char_boundary(result.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_empty() {
|
||||
assert_eq!(truncate_utf8("", 80), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_zero_limit() {
|
||||
assert_eq!(truncate_utf8("hello", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_emoji() {
|
||||
// 🦀 is 4 bytes
|
||||
let s = "aaa🦀bbb";
|
||||
assert_eq!(truncate_utf8(s, 4), "aaa");
|
||||
assert_eq!(truncate_utf8(s, 7), "aaa🦀");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! Minimal Language Server Protocol client for the agent's code-navigation tool
|
||||
//! (design §3.3 "LSP 工具": goToDefinition / findReferences / documentSymbol /
|
||||
//! hover). Hand-rolled (no new crate dep, keeping the agent layer dependency-
|
||||
//! light) and deliberately small: only the handful of methods the tool needs.
|
||||
//!
|
||||
//! # Status: experimental, opt-in, default OFF
|
||||
//!
|
||||
//! Registered only when `tools.lsp_servers` maps a file extension to a server
|
||||
//! command, so existing behaviour is unchanged. The two bug-prone, spec-exact
|
||||
//! pieces — Content-Length framing ([`codec`]) and UTF-16 position conversion
|
||||
//! ([`position`]) — are unit-tested here. The live server handshake / request
|
||||
//! path in [`client`] cannot be exercised without a real language server in the
|
||||
//! environment; treat it as experimental until validated against one.
|
||||
|
||||
pub mod codec {
|
||||
//! `Content-Length`-framed JSON-RPC message framing (LSP base protocol).
|
||||
|
||||
/// Encode a JSON payload as an LSP base-protocol message:
|
||||
/// `Content-Length: N\r\n\r\n<json>`. The length is the payload's **byte**
|
||||
/// length, not its char length.
|
||||
pub fn encode_message(json: &str) -> Vec<u8> {
|
||||
let mut out = format!("Content-Length: {}\r\n\r\n", json.len()).into_bytes();
|
||||
out.extend_from_slice(json.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Try to split one complete framed message off the front of `buf`. On
|
||||
/// success the consumed bytes (header + body) are drained from `buf` and the
|
||||
/// JSON body is returned. Returns `None` when `buf` does not yet hold a full
|
||||
/// message (caller should read more bytes and retry). Malformed headers
|
||||
/// (missing/invalid Content-Length) drain the bad header and return `None`
|
||||
/// so the stream can resynchronise rather than wedge.
|
||||
pub fn try_decode(buf: &mut Vec<u8>) -> Option<String> {
|
||||
// Find the header/body separator.
|
||||
let sep = find_subsequence(buf, b"\r\n\r\n")?;
|
||||
let header = &buf[..sep];
|
||||
let header_str = String::from_utf8_lossy(header);
|
||||
let content_len = header_str
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (k, v) = line.split_once(':')?;
|
||||
if k.trim().eq_ignore_ascii_case("Content-Length") {
|
||||
v.trim().parse::<usize>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let body_start = sep + 4;
|
||||
let Some(content_len) = content_len else {
|
||||
// Bad header: drop it so a later valid frame can be found.
|
||||
buf.drain(..body_start);
|
||||
return None;
|
||||
};
|
||||
if buf.len() < body_start + content_len {
|
||||
return None; // body not fully arrived yet
|
||||
}
|
||||
let body = buf[body_start..body_start + content_len].to_vec();
|
||||
buf.drain(..body_start + content_len);
|
||||
Some(String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_uses_byte_length_and_crlf_framing() {
|
||||
// "héllo" is 6 bytes (é = 2 bytes), 5 chars — length must be 6.
|
||||
let framed = encode_message("héllo");
|
||||
let s = String::from_utf8_lossy(&framed);
|
||||
assert!(s.starts_with("Content-Length: 6\r\n\r\n"), "got: {s:?}");
|
||||
assert!(s.ends_with("héllo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_roundtrips_a_single_message() {
|
||||
let mut buf = encode_message("{\"jsonrpc\":\"2.0\"}");
|
||||
let body = try_decode(&mut buf).unwrap();
|
||||
assert_eq!(body, "{\"jsonrpc\":\"2.0\"}");
|
||||
assert!(buf.is_empty(), "consumed bytes must be drained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_handles_two_concatenated_messages() {
|
||||
let mut buf = encode_message("AAA");
|
||||
buf.extend(encode_message("BB"));
|
||||
assert_eq!(try_decode(&mut buf).unwrap(), "AAA");
|
||||
assert_eq!(try_decode(&mut buf).unwrap(), "BB");
|
||||
assert!(try_decode(&mut buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_waits_for_incomplete_body() {
|
||||
let full = encode_message("HELLO");
|
||||
// Feed everything except the last byte.
|
||||
let mut buf = full[..full.len() - 1].to_vec();
|
||||
assert!(try_decode(&mut buf).is_none(), "must wait for the full body");
|
||||
buf.push(full[full.len() - 1]);
|
||||
assert_eq!(try_decode(&mut buf).unwrap(), "HELLO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_skips_a_malformed_header_to_resync() {
|
||||
// A header with no Content-Length is dropped; the following valid
|
||||
// frame still decodes.
|
||||
let mut buf = b"Garbage: 1\r\n\r\n".to_vec();
|
||||
buf.extend(encode_message("OK"));
|
||||
assert!(try_decode(&mut buf).is_none()); // drops the bad header
|
||||
assert_eq!(try_decode(&mut buf).unwrap(), "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod position {
|
||||
//! Conversion between editor-style 1-based char columns and LSP's 0-based
|
||||
//! UTF-16 code-unit positions. LSP `Position.character` counts UTF-16 code
|
||||
//! units by default — getting this wrong silently mis-targets every request
|
||||
//! on any line containing non-BMP characters (emoji, some CJK), so it is
|
||||
//! tested explicitly.
|
||||
|
||||
/// Convert a 1-based character column (counting Unicode scalar values, the
|
||||
/// usual editor convention) on `line_text` into a 0-based UTF-16 code-unit
|
||||
/// offset for LSP. A column past the end clamps to the line's UTF-16 length.
|
||||
pub fn char_col_to_utf16(line_text: &str, char_col_1based: usize) -> u32 {
|
||||
let take = char_col_1based.saturating_sub(1);
|
||||
line_text
|
||||
.chars()
|
||||
.take(take)
|
||||
.map(|c| c.len_utf16() as u32)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Convert a 0-based UTF-16 offset (as returned by a server) back to a
|
||||
/// 1-based character column for display. An offset past the end clamps to
|
||||
/// the line's char length + 1.
|
||||
pub fn utf16_to_char_col(line_text: &str, utf16_offset: u32) -> usize {
|
||||
let mut remaining = utf16_offset;
|
||||
let mut chars = 0usize;
|
||||
for c in line_text.chars() {
|
||||
let w = c.len_utf16() as u32;
|
||||
if remaining < w {
|
||||
break;
|
||||
}
|
||||
remaining -= w;
|
||||
chars += 1;
|
||||
}
|
||||
chars + 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_columns_are_one_to_one() {
|
||||
assert_eq!(char_col_to_utf16("hello", 1), 0);
|
||||
assert_eq!(char_col_to_utf16("hello", 3), 2);
|
||||
assert_eq!(char_col_to_utf16("hello", 6), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bmp_chars_are_one_utf16_unit_each() {
|
||||
// CJK characters are single UTF-16 units.
|
||||
assert_eq!(char_col_to_utf16("你好world", 3), 2); // after 你好
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bmp_chars_are_two_utf16_units() {
|
||||
// "a😀b": 😀 (U+1F600) is a surrogate pair = 2 UTF-16 units.
|
||||
// Column 3 (1-based) = after "a😀" = 1 + 2 = 3 UTF-16 units.
|
||||
assert_eq!(char_col_to_utf16("a😀b", 3), 3);
|
||||
assert_eq!(char_col_to_utf16("a😀b", 2), 1); // after "a"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_to_char_col_inverts_the_conversion() {
|
||||
let line = "a😀b€c"; // €=1 unit, 😀=2 units
|
||||
for col in 1..=6 {
|
||||
let u16 = char_col_to_utf16(line, col);
|
||||
assert_eq!(utf16_to_char_col(line, u16), col, "col {col} round-trips");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
//! A session-cached LSP client: one server process per (command, root),
|
||||
//! reused across tool calls so the server is indexed once and stays warm.
|
||||
//! Requests are serialized by the caller (the tool holds the client behind a
|
||||
//! mutex), so a simple send-then-read-until-matching-id loop is correct
|
||||
//! without a concurrent dispatcher.
|
||||
//!
|
||||
//! EXPERIMENTAL: the live handshake/request path is not exercisable without a
|
||||
//! real language server in the environment. Server→client requests (e.g.
|
||||
//! `workspace/configuration`) are currently ignored and rely on the overall
|
||||
//! timeout; a server that blocks on them will time out rather than hang.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout};
|
||||
|
||||
use super::codec;
|
||||
|
||||
/// Overall deadline for any single request (covers cold-start indexing).
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct LspClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: ChildStdout,
|
||||
buf: Vec<u8>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl LspClient {
|
||||
/// Spawn `command` (program + args) rooted at `root` and complete the
|
||||
/// `initialize` / `initialized` handshake.
|
||||
pub async fn start(command: &[String], root: &Path) -> Result<Self, String> {
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| "empty LSP server command".to_string())?;
|
||||
let mut child = tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to spawn LSP server '{program}': {e}"))?;
|
||||
let stdin = child.stdin.take().ok_or("no stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("no stdout")?;
|
||||
let mut c = Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
buf: Vec::new(),
|
||||
next_id: 0,
|
||||
};
|
||||
let root_uri = path_to_uri(root);
|
||||
c.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"processId": std::process::id(),
|
||||
"rootUri": root_uri,
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
|
||||
"definition": {}, "references": {}, "hover": {}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
c.notify("initialized", json!({})).await?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Send `textDocument/didOpen` so the server has the file contents.
|
||||
pub async fn did_open(&mut self, uri: &str, language_id: &str, text: &str) -> Result<(), String> {
|
||||
self.notify(
|
||||
"textDocument/didOpen",
|
||||
json!({
|
||||
"textDocument": { "uri": uri, "languageId": language_id, "version": 1, "text": text }
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a request and return its `result` (or an `Err` carrying the
|
||||
/// server's error message).
|
||||
pub async fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||
self.write(&msg).await?;
|
||||
self.read_until_id(id).await
|
||||
}
|
||||
|
||||
pub async fn notify(&mut self, method: &str, params: Value) -> Result<(), String> {
|
||||
let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params });
|
||||
self.write(&msg).await
|
||||
}
|
||||
|
||||
async fn write(&mut self, msg: &Value) -> Result<(), String> {
|
||||
let framed = codec::encode_message(&msg.to_string());
|
||||
self.stdin
|
||||
.write_all(&framed)
|
||||
.await
|
||||
.map_err(|e| format!("LSP write failed: {e}"))?;
|
||||
self.stdin.flush().await.map_err(|e| format!("LSP flush failed: {e}"))
|
||||
}
|
||||
|
||||
/// Read framed messages until the response with `id` arrives, skipping
|
||||
/// notifications and unrelated messages. Bounded by `REQUEST_TIMEOUT`.
|
||||
async fn read_until_id(&mut self, id: i64) -> Result<Value, String> {
|
||||
let deadline = tokio::time::Instant::now() + REQUEST_TIMEOUT;
|
||||
loop {
|
||||
// Drain any already-buffered complete frames first.
|
||||
while let Some(body) = codec::try_decode(&mut self.buf) {
|
||||
let v: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("LSP response parse error: {e}"))?;
|
||||
if v.get("id").and_then(|i| i.as_i64()) == Some(id) {
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("LSP server error: {err}"));
|
||||
}
|
||||
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
// Otherwise: a notification or a server→client request we
|
||||
// don't handle — ignore and keep reading.
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(format!("LSP request '{id}' timed out"));
|
||||
}
|
||||
let mut chunk = [0u8; 8192];
|
||||
let n = match tokio::time::timeout(remaining, self.stdout.read(&mut chunk)).await {
|
||||
Ok(Ok(0)) => return Err("LSP server closed the connection".to_string()),
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(e)) => return Err(format!("LSP read failed: {e}")),
|
||||
Err(_) => return Err(format!("LSP request '{id}' timed out")),
|
||||
};
|
||||
self.buf.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LspClient {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort: kill the server when the session is dropped.
|
||||
let _ = self.child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an absolute filesystem path to a `file://` URI (minimal, not a
|
||||
/// full RFC 3986 encoder — adequate for local paths).
|
||||
pub fn path_to_uri(path: &Path) -> String {
|
||||
let p = path.to_string_lossy().replace('\\', "/");
|
||||
if p.starts_with('/') {
|
||||
format!("file://{p}")
|
||||
} else {
|
||||
format!("file:///{p}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod tool {
|
||||
//! `Lsp` tool: code navigation via a configured language server. Registered
|
||||
//! only when `tools.lsp_servers` is non-empty (default off → no behaviour
|
||||
//! change). EXPERIMENTAL — see the module header.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use super::client::{LspClient, path_to_uri};
|
||||
use super::position::char_col_to_utf16;
|
||||
use crate::Tool;
|
||||
|
||||
pub struct LspTool {
|
||||
/// Maps a file extension (without dot, lowercase) to the server command.
|
||||
servers: HashMap<String, Vec<String>>,
|
||||
cwd: PathBuf,
|
||||
/// One live server per distinct command, reused across calls.
|
||||
sessions: Arc<Mutex<HashMap<String, Arc<Mutex<LspClient>>>>>,
|
||||
}
|
||||
|
||||
impl LspTool {
|
||||
pub fn new(servers: HashMap<String, Vec<String>>, cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
servers,
|
||||
cwd,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any server is configured (the bootstrap gate).
|
||||
pub fn has_servers(&self) -> bool {
|
||||
!self.servers.is_empty()
|
||||
}
|
||||
|
||||
async fn session_for(&self, command: &[String]) -> Result<Arc<Mutex<LspClient>>, String> {
|
||||
let key = command.join("\u{0}");
|
||||
let mut map = self.sessions.lock().await;
|
||||
if let Some(existing) = map.get(&key) {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let client = LspClient::start(command, &self.cwd).await?;
|
||||
let arc = Arc::new(Mutex::new(client));
|
||||
map.insert(key, arc.clone());
|
||||
Ok(arc)
|
||||
}
|
||||
}
|
||||
|
||||
fn err(msg: impl Into<String>) -> ToolResult {
|
||||
ToolResult { content: msg.into(), is_error: true, images: Vec::new() }
|
||||
}
|
||||
|
||||
/// LSP `languageId` for a file extension (a few common ones; falls back to
|
||||
/// the extension itself).
|
||||
fn language_id(ext: &str) -> &str {
|
||||
match ext {
|
||||
"rs" => "rust",
|
||||
"ts" => "typescript",
|
||||
"tsx" => "typescriptreact",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"jsx" => "javascriptreact",
|
||||
"py" => "python",
|
||||
"cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"rb" => "ruby",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LspTool {
|
||||
fn name(&self) -> &str {
|
||||
"Lsp"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Code navigation via a language server (experimental).\n\n\
|
||||
operation:\n\
|
||||
- documentSymbol: list the file's symbols (functions/classes/...). No position needed.\n\
|
||||
- definition / references / hover: require `line` and `character` (1-based, as shown in an editor).\n\
|
||||
Returns file:line locations. Configure servers under [tools] lsp_servers."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["documentSymbol", "definition", "references", "hover"],
|
||||
"description": "The navigation query to run."
|
||||
},
|
||||
"file_path": { "type": "string", "description": "File to query (absolute, or relative to the workspace)." },
|
||||
"line": { "type": "integer", "description": "1-based line (required for definition/references/hover)." },
|
||||
"character": { "type": "integer", "description": "1-based column (required for definition/references/hover)." }
|
||||
},
|
||||
"required": ["operation", "file_path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false // sessions are serialized
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let op = input.get("operation").and_then(|v| v.as_str()).unwrap_or("lsp");
|
||||
let f = input.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
format!("Lsp {op}: {}", crate::truncate_utf8(f, 60))
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(operation) = input["operation"].as_str() else {
|
||||
return err("Missing required parameter: operation");
|
||||
};
|
||||
let Some(file_path) = input["file_path"].as_str() else {
|
||||
return err("Missing required parameter: file_path");
|
||||
};
|
||||
let abs = {
|
||||
let p = Path::new(file_path);
|
||||
if p.is_absolute() { p.to_path_buf() } else { self.cwd.join(p) }
|
||||
};
|
||||
let ext = abs
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let Some(command) = self.servers.get(&ext).cloned() else {
|
||||
return err(format!(
|
||||
"No LSP server configured for '.{ext}'. Add one under [tools] lsp_servers."
|
||||
));
|
||||
};
|
||||
let text = match std::fs::read_to_string(&abs) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return err(format!("Failed to read {}: {e}", abs.display())),
|
||||
};
|
||||
let uri = path_to_uri(&abs);
|
||||
|
||||
let session = match self.session_for(&command).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let mut client = session.lock().await;
|
||||
if let Err(e) = client.did_open(&uri, language_id(&ext), &text).await {
|
||||
return err(e);
|
||||
}
|
||||
|
||||
// Position-bearing operations need line+character.
|
||||
let position = || -> Result<Value, String> {
|
||||
let line = input["line"].as_u64().ok_or("`line` is required for this operation")? as usize;
|
||||
let character = input["character"].as_u64().ok_or("`character` is required")? as usize;
|
||||
let line_text = text.lines().nth(line.saturating_sub(1)).unwrap_or("");
|
||||
Ok(json!({ "line": line.saturating_sub(1), "character": char_col_to_utf16(line_text, character) }))
|
||||
};
|
||||
|
||||
let result = match operation {
|
||||
"documentSymbol" => {
|
||||
client.request("textDocument/documentSymbol", json!({ "textDocument": { "uri": uri } })).await
|
||||
}
|
||||
"definition" => match position() {
|
||||
Ok(pos) => {
|
||||
client.request("textDocument/definition", json!({ "textDocument": { "uri": uri }, "position": pos })).await
|
||||
}
|
||||
Err(e) => return err(e),
|
||||
},
|
||||
"references" => match position() {
|
||||
Ok(pos) => {
|
||||
client.request("textDocument/references", json!({ "textDocument": { "uri": uri }, "position": pos, "context": { "includeDeclaration": true } })).await
|
||||
}
|
||||
Err(e) => return err(e),
|
||||
},
|
||||
"hover" => match position() {
|
||||
Ok(pos) => {
|
||||
client.request("textDocument/hover", json!({ "textDocument": { "uri": uri }, "position": pos })).await
|
||||
}
|
||||
Err(e) => return err(e),
|
||||
},
|
||||
other => return err(format!("Unknown operation '{other}'")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => ToolResult {
|
||||
content: format_result(operation, &value),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
},
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a server result into a compact, human/LLM-readable form.
|
||||
fn format_result(operation: &str, value: &Value) -> String {
|
||||
match operation {
|
||||
"documentSymbol" => format_symbols(value),
|
||||
"hover" => format_hover(value),
|
||||
_ => format_locations(value), // definition / references
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: u64) -> &'static str {
|
||||
// LSP SymbolKind (1-26).
|
||||
match kind {
|
||||
1 => "file", 2 => "module", 3 => "namespace", 4 => "package", 5 => "class",
|
||||
6 => "method", 7 => "property", 8 => "field", 9 => "constructor", 10 => "enum",
|
||||
11 => "interface", 12 => "function", 13 => "variable", 14 => "constant",
|
||||
15 => "string", 16 => "number", 17 => "boolean", 18 => "array", 19 => "object",
|
||||
20 => "key", 21 => "null", 22 => "enum-member", 23 => "struct", 24 => "event",
|
||||
25 => "operator", 26 => "type-param", _ => "symbol",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_symbols(value: &Value) -> String {
|
||||
let Some(arr) = value.as_array() else {
|
||||
return "(no symbols)".to_string();
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return "(no symbols)".to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
fn walk(out: &mut String, node: &Value, depth: usize) {
|
||||
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let kind = node.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
// DocumentSymbol uses `range`; SymbolInformation uses `location.range`.
|
||||
let line = node
|
||||
.get("range")
|
||||
.or_else(|| node.get("location").and_then(|l| l.get("range")))
|
||||
.and_then(|r| r.get("start"))
|
||||
.and_then(|s| s.get("line"))
|
||||
.and_then(|l| l.as_u64())
|
||||
.map(|l| l + 1)
|
||||
.unwrap_or(0);
|
||||
out.push_str(&format!(
|
||||
"{}{} ({}) :{}\n",
|
||||
" ".repeat(depth),
|
||||
name,
|
||||
symbol_kind_name(kind),
|
||||
line
|
||||
));
|
||||
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
|
||||
for child in children {
|
||||
walk(out, child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for node in arr {
|
||||
walk(&mut out, node, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn uri_to_display(uri: &str) -> String {
|
||||
uri.strip_prefix("file://").map(|s| s.to_string()).unwrap_or_else(|| uri.to_string())
|
||||
}
|
||||
|
||||
fn format_locations(value: &Value) -> String {
|
||||
// The result may be a single Location, an array of Location, or null.
|
||||
let locations: Vec<&Value> = match value {
|
||||
Value::Array(arr) => arr.iter().collect(),
|
||||
Value::Null => Vec::new(),
|
||||
single => vec![single],
|
||||
};
|
||||
if locations.is_empty() {
|
||||
return "(no results)".to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for loc in locations {
|
||||
let uri = loc.get("uri").or_else(|| loc.get("targetUri")).and_then(|u| u.as_str()).unwrap_or("");
|
||||
let line = loc
|
||||
.get("range")
|
||||
.or_else(|| loc.get("targetSelectionRange"))
|
||||
.and_then(|r| r.get("start"))
|
||||
.and_then(|s| s.get("line"))
|
||||
.and_then(|l| l.as_u64())
|
||||
.map(|l| l + 1)
|
||||
.unwrap_or(0);
|
||||
out.push_str(&format!("{}:{}\n", uri_to_display(uri), line));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn format_hover(value: &Value) -> String {
|
||||
let contents = value.get("contents");
|
||||
match contents {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Object(o)) => o.get("value").and_then(|v| v.as_str()).unwrap_or("(no hover)").to_string(),
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Object(o) => o.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => "(no hover)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_server_configured_is_a_clear_error() {
|
||||
let tool = LspTool::new(HashMap::new(), std::env::temp_dir());
|
||||
assert!(!tool.has_servers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_symbols_renders_a_hierarchical_tree() {
|
||||
let v = json!([
|
||||
{ "name": "Foo", "kind": 5, "range": { "start": { "line": 9 } },
|
||||
"children": [ { "name": "bar", "kind": 6, "range": { "start": { "line": 11 } } } ] }
|
||||
]);
|
||||
let out = format_symbols(&v);
|
||||
assert!(out.contains("Foo (class) :10"), "got: {out}");
|
||||
assert!(out.contains(" bar (method) :12"), "nested + 1-based line: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_locations_handles_single_array_and_null() {
|
||||
assert_eq!(format_locations(&Value::Null), "(no results)");
|
||||
let one = json!({ "uri": "file:///a/b.rs", "range": { "start": { "line": 41 } } });
|
||||
assert_eq!(format_locations(&one), "/a/b.rs:42\n");
|
||||
let many = json!([
|
||||
{ "uri": "file:///x.rs", "range": { "start": { "line": 0 } } },
|
||||
{ "uri": "file:///y.rs", "range": { "start": { "line": 4 } } }
|
||||
]);
|
||||
assert_eq!(format_locations(&many), "/x.rs:1\n/y.rs:5\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_hover_extracts_markup_and_plain_and_array() {
|
||||
assert_eq!(format_hover(&json!({ "contents": "plain" })), "plain");
|
||||
assert_eq!(format_hover(&json!({ "contents": { "kind": "markdown", "value": "**md**" } })), "**md**");
|
||||
assert_eq!(format_hover(&json!({ "contents": ["a", { "value": "b" }] })), "a\nb");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use tool::LspTool;
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Head/tail output truncation for tool results.
|
||||
//!
|
||||
//! Preserves a prefix and a suffix on UTF-8 boundaries, dropping the middle
|
||||
//! and inserting a marker that records how much was removed. Ported (and
|
||||
//! de-dependency-ed) from codex `utils/string/src/truncate.rs`.
|
||||
//!
|
||||
//! Unlike the engine-level fallback in `nomi-agent::orchestration` (private,
|
||||
//! char-counted, multi-pass), this is a reusable, single-pass, tested pure
|
||||
//! function so any tool (Bash today; Grep/Read later) can bound its output.
|
||||
|
||||
const APPROX_BYTES_PER_TOKEN: usize = 4;
|
||||
|
||||
/// How much output to retain before the middle is elided.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TruncationBudget {
|
||||
/// Retain at most this many bytes (split across head/tail).
|
||||
Bytes(usize),
|
||||
/// Retain at most ~this many tokens, estimated at 4 bytes/token.
|
||||
Tokens(usize),
|
||||
}
|
||||
|
||||
impl TruncationBudget {
|
||||
fn byte_budget(self) -> usize {
|
||||
match self {
|
||||
TruncationBudget::Bytes(b) => b,
|
||||
TruncationBudget::Tokens(t) => t.saturating_mul(APPROX_BYTES_PER_TOKEN),
|
||||
}
|
||||
}
|
||||
fn use_tokens(self) -> bool {
|
||||
matches!(self, TruncationBudget::Tokens(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate `s` to `budget`, keeping the head and tail and eliding the middle.
|
||||
///
|
||||
/// Returns the original string untouched when it already fits. Otherwise the
|
||||
/// result is `<head><marker><tail>` where the marker reports the elided amount,
|
||||
/// e.g. `…12345 chars truncated…`. UTF-8 char boundaries are always respected.
|
||||
pub fn truncate_middle(s: &str, budget: TruncationBudget) -> String {
|
||||
let max_bytes = budget.byte_budget();
|
||||
let use_tokens = budget.use_tokens();
|
||||
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if max_bytes == 0 {
|
||||
let total_chars = s.chars().count();
|
||||
return marker(use_tokens, removed_units(use_tokens, s.len(), total_chars));
|
||||
}
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let total_bytes = s.len();
|
||||
let (left_budget, right_budget) = split_budget(max_bytes);
|
||||
let (removed_chars, left, right) = split_string(s, left_budget, right_budget);
|
||||
let marker = marker(
|
||||
use_tokens,
|
||||
removed_units(use_tokens, total_bytes.saturating_sub(max_bytes), removed_chars),
|
||||
);
|
||||
|
||||
let mut out = String::with_capacity(left.len() + marker.len() + right.len());
|
||||
out.push_str(left);
|
||||
out.push_str(&marker);
|
||||
out.push_str(right);
|
||||
out
|
||||
}
|
||||
|
||||
/// Approximate token count for a string (~4 bytes/token), saturating (ceil).
|
||||
pub fn approx_token_count(text: &str) -> usize {
|
||||
text.len()
|
||||
.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1))
|
||||
/ APPROX_BYTES_PER_TOKEN
|
||||
}
|
||||
|
||||
fn split_budget(budget: usize) -> (usize, usize) {
|
||||
let left = budget / 2;
|
||||
(left, budget - left)
|
||||
}
|
||||
|
||||
/// Walk char boundaries: fill `beginning_bytes` into the prefix, find the first
|
||||
/// char whose start lands in the trailing `end_bytes` window for the suffix,
|
||||
/// and count the chars dropped in between. All slice boundaries are guaranteed
|
||||
/// to land on char boundaries, so the returned `&str`s are always valid.
|
||||
fn split_string(s: &str, beginning_bytes: usize, end_bytes: usize) -> (usize, &str, &str) {
|
||||
let len = s.len();
|
||||
let tail_start_target = len.saturating_sub(end_bytes);
|
||||
let mut prefix_end = 0usize;
|
||||
let mut suffix_start = len;
|
||||
let mut removed_chars = 0usize;
|
||||
let mut suffix_started = false;
|
||||
|
||||
for (idx, ch) in s.char_indices() {
|
||||
let char_end = idx + ch.len_utf8();
|
||||
if char_end <= beginning_bytes {
|
||||
prefix_end = char_end;
|
||||
continue;
|
||||
}
|
||||
if idx >= tail_start_target {
|
||||
if !suffix_started {
|
||||
suffix_start = idx;
|
||||
suffix_started = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
removed_chars = removed_chars.saturating_add(1);
|
||||
}
|
||||
|
||||
if suffix_start < prefix_end {
|
||||
suffix_start = prefix_end;
|
||||
}
|
||||
(removed_chars, &s[..prefix_end], &s[suffix_start..])
|
||||
}
|
||||
|
||||
fn marker(use_tokens: bool, removed: u64) -> String {
|
||||
if use_tokens {
|
||||
format!("\n…{removed} tokens truncated…\n")
|
||||
} else {
|
||||
format!("\n…{removed} chars truncated…\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn removed_units(use_tokens: bool, removed_bytes: usize, removed_chars: usize) -> u64 {
|
||||
if use_tokens {
|
||||
(removed_bytes as u64).saturating_add(APPROX_BYTES_PER_TOKEN as u64 - 1)
|
||||
/ APPROX_BYTES_PER_TOKEN as u64
|
||||
} else {
|
||||
u64::try_from(removed_chars).unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn small_input_unchanged() {
|
||||
assert_eq!(truncate_middle("hello", TruncationBudget::Bytes(50_000)), "hello");
|
||||
// exactly at budget is also unchanged
|
||||
assert_eq!(truncate_middle("hello", TruncationBudget::Bytes(5)), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
assert_eq!(truncate_middle("", TruncationBudget::Bytes(10)), "");
|
||||
assert_eq!(truncate_middle("", TruncationBudget::Bytes(0)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_input_keeps_head_and_tail() {
|
||||
let input = format!("{}{}", "0".repeat(100), "1".repeat(100));
|
||||
let result = truncate_middle(&input, TruncationBudget::Bytes(20));
|
||||
assert!(result.starts_with('0'), "should keep head: {result}");
|
||||
assert!(result.ends_with('1'), "should keep tail: {result}");
|
||||
assert!(result.contains("chars truncated"), "should mark elision: {result}");
|
||||
assert!(result.len() < input.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_reports_removed_count() {
|
||||
let input = "a".repeat(100);
|
||||
let result = truncate_middle(&input, TruncationBudget::Bytes(20));
|
||||
// total_bytes - max_bytes = 100 - 20 = 80
|
||||
assert!(result.contains("80 chars truncated"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_boundary_safe_multibyte() {
|
||||
let input = "é".repeat(100); // 2 bytes each => 200 bytes
|
||||
let result = truncate_middle(&input, TruncationBudget::Bytes(21)); // odd budget
|
||||
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
|
||||
assert!(!result.contains('\u{FFFD}'), "no replacement chars");
|
||||
// every byte index that starts a slice must be a char boundary (no panic implies it)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_boundary_safe_emoji() {
|
||||
let input = "🦀".repeat(50); // 4 bytes each => 200 bytes
|
||||
let result = truncate_middle(&input, TruncationBudget::Bytes(10));
|
||||
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
|
||||
assert!(result.starts_with('🦀'), "head crab intact: {result}");
|
||||
assert!(result.ends_with('🦀'), "tail crab intact: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_zero_returns_only_marker() {
|
||||
let result = truncate_middle("hello world", TruncationBudget::Bytes(0));
|
||||
assert!(result.contains("chars truncated"));
|
||||
assert!(!result.contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_one_no_overlap_no_panic() {
|
||||
let input = "abcdefghij";
|
||||
let result = truncate_middle(input, TruncationBudget::Bytes(1));
|
||||
// head gets 0 bytes (1/2), tail gets 1 byte; no overlap, valid utf8
|
||||
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
|
||||
assert!(result.contains("chars truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_budget_path() {
|
||||
let input = "a".repeat(100);
|
||||
// Tokens(5) => 20 bytes budget, input is 100 bytes => truncated
|
||||
let result = truncate_middle(&input, TruncationBudget::Tokens(5));
|
||||
assert!(result.contains("tokens truncated"), "got: {result}");
|
||||
// small input under token budget is unchanged
|
||||
assert_eq!(truncate_middle("abcd", TruncationBudget::Tokens(5)), "abcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_token_count_basic() {
|
||||
assert_eq!(approx_token_count(""), 0);
|
||||
assert_eq!(approx_token_count("abcd"), 1);
|
||||
assert_eq!(approx_token_count("abcde"), 2); // ceil(5/4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Write-root containment guard (design §3.6 "写根包含校验").
|
||||
//!
|
||||
//! An **opt-in** guardrail: when a write root is configured, the file-mutating
|
||||
//! tools (Write / Edit / ApplyPatch) refuse to write outside it. Default is no
|
||||
//! root → no containment, so existing behaviour is byte-for-byte unchanged.
|
||||
//!
|
||||
//! # Threat model (honest scope)
|
||||
//!
|
||||
//! This stops *accidental or buggy* out-of-workspace writes (a bad absolute
|
||||
//! path, a `../../` traversal, or a symlink that escapes the root). It is **not**
|
||||
//! a security sandbox against a determined agent: the same agent has `Bash`, so
|
||||
//! a real boundary needs OS-level confinement (macOS Seatbelt / Linux
|
||||
//! namespaces), which is a separate, runtime-verified piece. Scoping it this way
|
||||
//! avoids a false sense of safety.
|
||||
//!
|
||||
//! # Symlink correctness
|
||||
//!
|
||||
//! Containment is checked against the **canonicalised** path, not the textual
|
||||
//! one: we resolve the longest existing ancestor (which collapses `..` and
|
||||
//! follows symlinks) and re-append the not-yet-existing tail. A symlink inside
|
||||
//! the root that points outside therefore resolves outside and is rejected —
|
||||
//! textual `starts_with` alone would be fooled by it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve `path` for containment checking: canonicalise the longest existing
|
||||
/// ancestor (resolving symlinks and `..`), then re-append the remaining
|
||||
/// not-yet-existing components. Returns `None` if no ancestor exists or the
|
||||
/// path has no components.
|
||||
fn resolve_existing_prefix(path: &Path) -> Option<PathBuf> {
|
||||
// Fast path: the whole path exists (existing file or dir).
|
||||
if let Ok(c) = path.canonicalize() {
|
||||
return Some(c);
|
||||
}
|
||||
// Walk up to the nearest existing ancestor, canonicalise it, then re-attach
|
||||
// the trailing components that do not exist yet.
|
||||
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut cur = path;
|
||||
loop {
|
||||
match cur.parent() {
|
||||
Some(parent) => {
|
||||
if let Some(name) = cur.file_name() {
|
||||
tail.push(name.to_os_string());
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
if let Ok(c) = parent.canonicalize() {
|
||||
let mut resolved = c;
|
||||
for component in tail.iter().rev() {
|
||||
resolved.push(component);
|
||||
}
|
||||
return Some(resolved);
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is contained within `root` after both are canonicalised.
|
||||
/// A `root` that cannot be canonicalised (does not exist) yields `false` —
|
||||
/// callers treat that as "cannot prove containment" → reject.
|
||||
pub fn is_within_root(path: &Path, root: &Path) -> bool {
|
||||
let Ok(root_c) = root.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
match resolve_existing_prefix(path) {
|
||||
Some(target_c) => target_c.starts_with(&root_c),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a model-supplied `file_path` for the file-mutating tools
|
||||
/// (Write / Edit / ApplyPatch): a relative path is joined onto the session
|
||||
/// working directory `cwd` (matching ReadTool / Grep / Glob / Bash); an
|
||||
/// absolute path is returned unchanged. `cwd == None` leaves the path as-is, so
|
||||
/// relative paths then resolve against the process cwd — the legacy behaviour.
|
||||
///
|
||||
/// This closes the read/write asymmetry: without it a relative path written by
|
||||
/// the model lands against the Tauri process cwd rather than the conversation's
|
||||
/// workspace, producing a truthful "Created …" while the file never appears in
|
||||
/// the workspace the UI browses.
|
||||
pub fn resolve_against_cwd(file_path: &str, cwd: Option<&Path>) -> String {
|
||||
match cwd {
|
||||
Some(cwd) if !Path::new(file_path).is_absolute() => {
|
||||
cwd.join(file_path).to_string_lossy().into_owned()
|
||||
}
|
||||
_ => file_path.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard a write to `file_path` against an optional `root`. Returns `Some(error)`
|
||||
/// when the write must be rejected, `None` when allowed (no root, or contained).
|
||||
pub fn ensure_within_root(file_path: &str, root: Option<&Path>) -> Option<String> {
|
||||
let root = root?;
|
||||
if is_within_root(Path::new(file_path), root) {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Write rejected: {} is outside the allowed write root {}. \
|
||||
(Move the target inside the workspace, or disable tools.write_root.)",
|
||||
file_path,
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn allows_existing_file_inside_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
fs::write(&f, "x").unwrap();
|
||||
assert!(is_within_root(&f, dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_new_file_inside_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("sub/new.txt"); // sub/ may not exist yet
|
||||
// parent sub/ does not exist; resolve_existing_prefix walks to dir.
|
||||
assert!(is_within_root(&f, dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_path_outside_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let f = other.path().join("escape.txt");
|
||||
assert!(!is_within_root(&f, dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_traversal_escape() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("root");
|
||||
fs::create_dir(&root).unwrap();
|
||||
// root/../sibling.txt resolves to dir/sibling.txt — outside root.
|
||||
let escape = root.join("../sibling.txt");
|
||||
assert!(!is_within_root(&escape, &root));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_escaping_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("root");
|
||||
let outside = dir.path().join("outside");
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::create_dir(&outside).unwrap();
|
||||
// root/link -> outside ; a write to root/link/file actually lands outside.
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||
let via_link = root.join("link/file.txt");
|
||||
assert!(
|
||||
!is_within_root(&via_link, &root),
|
||||
"a symlink escaping the root must be rejected (textual check would pass)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_against_cwd_joins_relative_and_keeps_absolute() {
|
||||
// Use real absolute dirs so the test is platform-agnostic (a leading "/"
|
||||
// is NOT absolute on Windows, so hardcoded unix paths would be wrong here).
|
||||
let cwd = std::env::temp_dir();
|
||||
// Relative → joined onto cwd (expected computed with the same join so the
|
||||
// separator is platform-correct).
|
||||
assert_eq!(
|
||||
resolve_against_cwd("notes.txt", Some(&cwd)),
|
||||
cwd.join("notes.txt").to_string_lossy().into_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_against_cwd("a/b.txt", Some(&cwd)),
|
||||
cwd.join("a/b.txt").to_string_lossy().into_owned()
|
||||
);
|
||||
// Absolute input → returned unchanged.
|
||||
let abs = cwd.join("already_absolute.txt");
|
||||
let abs_str = abs.to_str().unwrap();
|
||||
assert_eq!(resolve_against_cwd(abs_str, Some(&cwd)), abs_str);
|
||||
// No cwd → unchanged (legacy: relative resolves against the process cwd).
|
||||
assert_eq!(resolve_against_cwd("notes.txt", None), "notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_within_root_is_noop_without_a_root() {
|
||||
// No configured root → never rejects (default behaviour unchanged).
|
||||
assert!(ensure_within_root("/anywhere/at/all.txt", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_within_root_rejects_outside() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let outside = other.path().join("x.txt");
|
||||
assert!(ensure_within_root(outside.to_str().unwrap(), Some(dir.path())).is_some());
|
||||
let inside = dir.path().join("x.txt");
|
||||
assert!(ensure_within_root(inside.to_str().unwrap(), Some(dir.path())).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! `PersistentShell`: a single long-lived shell process in a PTY whose working
|
||||
//! directory and environment persist across sequential commands — the
|
||||
//! difference between `BashTool`'s stateless one-shot (`cd foo` forgotten on the
|
||||
//! next call) and a real interactive session.
|
||||
//!
|
||||
//! # Completion protocol (controlled sentinel)
|
||||
//!
|
||||
//! After each submitted command the shell is told to print a unique sentinel
|
||||
//! line carrying the command's exit status:
|
||||
//!
|
||||
//! ```text
|
||||
//! <command>
|
||||
//! printf '__NOMI_END_<nonce>__%d__\n' "$?"
|
||||
//! ```
|
||||
//!
|
||||
//! We then read PTY output until that exact `__NOMI_END_<nonce>__<rc>__` line
|
||||
//! appears; everything before it is the command's output and `<rc>` is its exit
|
||||
//! code. Input echo is disabled (`stty -echo`) and the prompt is blanked at init
|
||||
//! so the captured output contains only the command's own stdout/stderr.
|
||||
//!
|
||||
//! This is **not** the unreliable "scrape markers out of an interactive TUI's
|
||||
//! redrawing output" mechanism that was removed from terminal AutoWork: the
|
||||
//! shell is a line-oriented program whose command line we fully control, and the
|
||||
//! sentinel is emitted by a `printf` we appended — the standard technique used
|
||||
//! by every persistent-shell coding tool. Detection is exact, not heuristic.
|
||||
//!
|
||||
//! Unix-only. The host falls back to the stateless `BashTool` on Windows / when
|
||||
//! the feature is disabled.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::pty::{Pty, PtyParams};
|
||||
|
||||
/// Ctrl-C (ETX): interrupts the foreground command on a PTY.
|
||||
const CTRL_C: u8 = 0x03;
|
||||
|
||||
/// How long to wait for the shell to reach its first ready sentinel at spawn.
|
||||
const INIT_READY_TIMEOUT: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// After a Ctrl-C on timeout, how long to wait for the interrupted command's
|
||||
/// sentinel to flush before giving up and respawning the shell.
|
||||
const INTERRUPT_RESYNC_GRACE: Duration = Duration::from_millis(1_000);
|
||||
|
||||
/// Outcome of running one command in the persistent shell.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ShellOutcome {
|
||||
/// Combined stdout/stderr (PTY-interleaved), with carriage returns stripped.
|
||||
pub output: String,
|
||||
/// The command's exit code.
|
||||
pub exit_code: i32,
|
||||
/// True when the command did not finish within the timeout (the shell was
|
||||
/// interrupted/respawned and `output` holds whatever had been produced).
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// A long-lived shell whose cwd/env persist across `run` calls. Commands are
|
||||
/// serialized through an internal lock (a single shell process cannot interleave
|
||||
/// commands), so this is cheap to share via `Arc`.
|
||||
pub struct PersistentShell {
|
||||
/// The directory the shell is (re)spawned in — also the recovery cwd after a
|
||||
/// timeout forces a respawn.
|
||||
spawn_cwd: String,
|
||||
/// Monotonic sentinel nonce; each command gets a fresh one.
|
||||
seq: AtomicU64,
|
||||
/// The live shell + its output accumulator, guarded so commands serialize.
|
||||
inner: Mutex<Option<Arc<Pty>>>,
|
||||
}
|
||||
|
||||
impl PersistentShell {
|
||||
/// Create a shell rooted at `cwd`. The shell process is spawned lazily on the
|
||||
/// first `run` (and respawned automatically if it dies or a timeout forces a
|
||||
/// reset).
|
||||
pub fn new(cwd: impl Into<String>) -> Self {
|
||||
Self {
|
||||
spawn_cwd: cwd.into(),
|
||||
seq: AtomicU64::new(1),
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `command`, returning its output and exit code. cwd/env mutations
|
||||
/// (`cd`, `export`) persist for subsequent calls. On timeout the foreground
|
||||
/// command is interrupted and, if it cannot be re-synced, the shell is
|
||||
/// respawned at the original cwd (losing in-shell state) and `timed_out` is
|
||||
/// set.
|
||||
pub async fn run(&self, command: &str, timeout: Duration) -> Result<ShellOutcome, String> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
|
||||
// (Re)spawn if there is no live shell.
|
||||
if guard.as_ref().map(|p| p.has_exited()).unwrap_or(true) {
|
||||
*guard = Some(self.spawn_ready().await?);
|
||||
}
|
||||
let pty = guard.as_ref().expect("just ensured present").clone();
|
||||
|
||||
let nonce = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
match Self::exec(&pty, command, nonce, timeout).await {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(partial) => {
|
||||
// Timed out: interrupt, try to re-sync to the (now-aborted)
|
||||
// command's sentinel, else respawn. Subscribe before the Ctrl-C
|
||||
// write so the flushed sentinel is not missed.
|
||||
let mut rx = pty.subscribe();
|
||||
pty.write(&[CTRL_C]).ok();
|
||||
let mut sink = String::new();
|
||||
if let Some(rc) = Self::collect_until_sentinel(
|
||||
&mut rx,
|
||||
&Self::sentinel_prefix(nonce),
|
||||
INTERRUPT_RESYNC_GRACE,
|
||||
&mut sink,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(ShellOutcome {
|
||||
output: partial,
|
||||
exit_code: rc,
|
||||
timed_out: true,
|
||||
});
|
||||
}
|
||||
// Unrecoverable — drop this shell so the next call respawns.
|
||||
pty.kill();
|
||||
*guard = None;
|
||||
Ok(ShellOutcome {
|
||||
output: partial,
|
||||
exit_code: 124, // conventional timeout exit code
|
||||
timed_out: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a shell and drive it to a known-clean state: echo off, blank
|
||||
/// prompts, then a priming sentinel we wait for so init noise is drained.
|
||||
async fn spawn_ready(&self) -> Result<Arc<Pty>, String> {
|
||||
let pty = Pty::spawn(PtyParams {
|
||||
program: "sh".to_owned(),
|
||||
args: Vec::new(),
|
||||
cwd: self.spawn_cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
cols: 200,
|
||||
rows: 50,
|
||||
})?;
|
||||
|
||||
// Subscribe BEFORE writing so the priming sentinel is not missed.
|
||||
let mut rx = pty.subscribe();
|
||||
// Disable input echo and blank the prompts so captured output is just the
|
||||
// command's own stdout/stderr, then prime with sentinel 0.
|
||||
let init = "stty -echo 2>/dev/null; PS1=''; PS2=''; unset PROMPT_COMMAND 2>/dev/null\n";
|
||||
pty.write(init.as_bytes())?;
|
||||
pty.write(Self::sentinel_command(0).as_bytes())?;
|
||||
|
||||
let mut sink = String::new();
|
||||
if Self::collect_until_sentinel(&mut rx, &Self::sentinel_prefix(0), INIT_READY_TIMEOUT, &mut sink)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
pty.kill();
|
||||
return Err("persistent shell did not become ready".to_owned());
|
||||
}
|
||||
Ok(pty)
|
||||
}
|
||||
|
||||
/// Submit `command` plus its sentinel and collect output until the sentinel
|
||||
/// arrives. `Err(partial_output)` on timeout / stream close.
|
||||
async fn exec(
|
||||
pty: &Arc<Pty>,
|
||||
command: &str,
|
||||
nonce: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<ShellOutcome, String> {
|
||||
// Subscribe BEFORE writing to avoid missing output.
|
||||
let mut rx = pty.subscribe();
|
||||
// command on its own line (submits it), then the sentinel printf reading
|
||||
// the command's `$?`.
|
||||
let submission = format!("{command}\n{}", Self::sentinel_command(nonce));
|
||||
pty.write(submission.as_bytes()).map_err(|_| String::new())?;
|
||||
|
||||
let prefix = Self::sentinel_prefix(nonce);
|
||||
let mut buf = String::new();
|
||||
match Self::collect_until_sentinel(&mut rx, &prefix, timeout, &mut buf).await {
|
||||
Some(_) => {
|
||||
let (start, rc) = Self::find_sentinel(&buf, &prefix).expect("sentinel just matched");
|
||||
Ok(ShellOutcome {
|
||||
output: Self::clean(&buf[..start]),
|
||||
exit_code: rc,
|
||||
timed_out: false,
|
||||
})
|
||||
}
|
||||
None => Err(Self::extract_output(&buf, &prefix)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read from `rx` into `sink` until a parseable sentinel for `prefix` appears
|
||||
/// or `timeout` elapses / the stream closes. Returns the parsed exit code.
|
||||
async fn collect_until_sentinel(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Vec<u8>>,
|
||||
prefix: &str,
|
||||
timeout: Duration,
|
||||
sink: &mut String,
|
||||
) -> Option<i32> {
|
||||
// A sentinel may already be present if the caller pre-filled `sink`.
|
||||
if let Some((_, rc)) = Self::find_sentinel(sink, prefix) {
|
||||
return Some(rc);
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return None;
|
||||
}
|
||||
match tokio::time::timeout(remaining, rx.recv()).await {
|
||||
Ok(Ok(chunk)) => {
|
||||
sink.push_str(&String::from_utf8_lossy(&chunk));
|
||||
if let Some((_, rc)) = Self::find_sentinel(sink, prefix) {
|
||||
return Some(rc);
|
||||
}
|
||||
}
|
||||
// Lagged: a chunk was dropped; keep going (best-effort output).
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
|
||||
// Stream closed (shell died) or timeout — give up.
|
||||
Ok(Err(_)) | Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `printf` that emits sentinel `nonce` carrying the prior command's `$?`.
|
||||
fn sentinel_command(nonce: u64) -> String {
|
||||
format!("printf '__NOMI_END_{nonce}__%d__\\n' \"$?\"\n")
|
||||
}
|
||||
|
||||
/// The literal prefix that precedes the exit code in the emitted sentinel.
|
||||
fn sentinel_prefix(nonce: u64) -> String {
|
||||
format!("__NOMI_END_{nonce}__")
|
||||
}
|
||||
|
||||
/// Scan **all** occurrences of `prefix` in `buf` and return the byte offset of
|
||||
/// the first one followed by `<digits>__` (the real sentinel), plus the code.
|
||||
/// Earlier occurrences with a non-numeric tail (e.g. the echoed `printf
|
||||
/// '...%d__'` command line, if echo was on) are skipped — this makes
|
||||
/// detection robust without depending on `stty -echo` timing.
|
||||
fn find_sentinel(buf: &str, prefix: &str) -> Option<(usize, i32)> {
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = buf[search_from..].find(prefix) {
|
||||
let start = search_from + rel;
|
||||
let after = &buf[start + prefix.len()..];
|
||||
if let Some(end) = after.find("__") {
|
||||
if let Ok(rc) = after[..end].parse::<i32>() {
|
||||
return Some((start, rc));
|
||||
}
|
||||
}
|
||||
search_from = start + prefix.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Output before the sentinel, used on the timeout path where no code parsed.
|
||||
fn extract_output(buf: &str, prefix: &str) -> String {
|
||||
match Self::find_sentinel(buf, prefix).map(|(s, _)| s).or_else(|| buf.find(prefix)) {
|
||||
Some(start) => Self::clean(&buf[..start]),
|
||||
None => Self::clean(buf),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip carriage returns and a single trailing newline from captured output.
|
||||
fn clean(s: &str) -> String {
|
||||
let s = s.replace('\r', "");
|
||||
s.strip_suffix('\n').unwrap_or(&s).to_owned()
|
||||
}
|
||||
|
||||
/// Test-only: the live shell's pid, if spawned.
|
||||
#[cfg(test)]
|
||||
async fn pid_for_test(&self) -> Option<u32> {
|
||||
self.inner.lock().await.as_ref().and_then(|p| p.pid())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PersistentShell {
|
||||
/// Kill the live shell (and its process group) on teardown. Without this the
|
||||
/// child `sh` lingers: its stdin never reaches EOF while the PTY reader
|
||||
/// thread still holds the master open, so it would outlive the session.
|
||||
fn drop(&mut self) {
|
||||
if let Some(pty) = self.inner.get_mut().take() {
|
||||
pty.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shell() -> PersistentShell {
|
||||
PersistentShell::new(std::env::temp_dir().to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
const T: Duration = Duration::from_millis(8_000);
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_command_and_returns_stdout() {
|
||||
let sh = shell();
|
||||
let out = sh.run("echo hello_shell", T).await.expect("run");
|
||||
assert_eq!(out.exit_code, 0, "output: {:?}", out.output);
|
||||
assert!(out.output.contains("hello_shell"), "got: {:?}", out.output);
|
||||
assert!(!out.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_nonzero_exit_code() {
|
||||
let sh = shell();
|
||||
// A subshell so the nonzero exit does not terminate the persistent shell.
|
||||
let out = sh.run("(exit 7)", T).await.expect("run");
|
||||
assert_eq!(out.exit_code, 7, "got: {:?}", out);
|
||||
assert!(!out.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cwd_persists_across_commands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("nested");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let sh = PersistentShell::new(dir.path().to_string_lossy().into_owned());
|
||||
|
||||
sh.run(&format!("cd {}", sub.display()), T).await.expect("cd");
|
||||
let out = sh.run("pwd", T).await.expect("pwd");
|
||||
assert!(
|
||||
out.output.contains("nested"),
|
||||
"cwd should persist across commands, got: {:?}",
|
||||
out.output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn env_persists_across_commands() {
|
||||
let sh = shell();
|
||||
sh.run("export NOMI_TEST_VAR=persisted_value", T).await.expect("export");
|
||||
let out = sh.run("echo $NOMI_TEST_VAR", T).await.expect("echo");
|
||||
assert!(
|
||||
out.output.contains("persisted_value"),
|
||||
"exported env should persist, got: {:?}",
|
||||
out.output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_is_recoverable() {
|
||||
let sh = shell();
|
||||
let out = sh
|
||||
.run("sleep 30", Duration::from_millis(600))
|
||||
.await
|
||||
.expect("run");
|
||||
assert!(out.timed_out, "sleep 30 with a 600ms budget must time out");
|
||||
// The shell must remain usable for the next command after a timeout.
|
||||
let after = sh.run("echo recovered", T).await.expect("post-timeout run");
|
||||
assert_eq!(after.exit_code, 0);
|
||||
assert!(after.output.contains("recovered"), "got: {:?}", after.output);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kills_shell_process_on_drop() {
|
||||
let sh = shell();
|
||||
sh.run("true", T).await.expect("spawn shell");
|
||||
let pid = sh.pid_for_test().await.expect("pid") as i32;
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "shell alive before drop");
|
||||
|
||||
drop(sh);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let mut dead = false;
|
||||
while start.elapsed() < Duration::from_millis(3000) {
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
dead = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(dead, "the shell process must be killed when PersistentShell is dropped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `ProcessStore`: a 64-way LRU registry of live interactive PTY sessions shared
|
||||
//! by the `exec_command` and `write_stdin` tools, plus the incremental-read
|
||||
//! collection loop they share.
|
||||
//!
|
||||
//! Ported (de-dependency-ed) from codex `unified_exec::process_manager`:
|
||||
//! - 64-way cap, protect the most-recently-used 8, prune exited-then-oldest
|
||||
//! (`process_id_to_prune_from_meta`),
|
||||
//! - prune is decided while holding the lock but the victim is `kill()`ed after
|
||||
//! the lock is released (`store_process`),
|
||||
//! - `collect_output_until_deadline` minus codex's pause/network machinery.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::broadcast::Receiver;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::pty::Pty;
|
||||
|
||||
/// Upper bound on concurrently retained sessions (codex `MAX_UNIFIED_EXEC_PROCESSES`).
|
||||
pub const MAX_PROCESSES: usize = 64;
|
||||
/// The N most-recently-used sessions are never pruned.
|
||||
const PROTECT_RECENT: usize = 8;
|
||||
|
||||
/// A live interactive session tracked by the store.
|
||||
pub struct ExecSession {
|
||||
pub id: u64,
|
||||
pub pty: Arc<Pty>,
|
||||
/// The command line, for display/debugging.
|
||||
pub command: String,
|
||||
pub tty: bool,
|
||||
pub last_used: Instant,
|
||||
}
|
||||
|
||||
/// Registry of live PTY sessions, keyed by a monotonic `u64` session id.
|
||||
pub struct ProcessStore {
|
||||
inner: Mutex<HashMap<u64, ExecSession>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for ProcessStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new session, pruning first if the store is full. Returns the new
|
||||
/// session id and, if a session was evicted to make room, its `Pty` so the
|
||||
/// caller can `kill()` it **after** releasing the store lock.
|
||||
pub async fn insert(&self, mut s: ExecSession) -> (u64, Option<Arc<Pty>>) {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
s.id = id;
|
||||
let mut map = self.inner.lock().await;
|
||||
let pruned = if map.len() >= MAX_PROCESSES {
|
||||
Self::pick_prune(&map).and_then(|pid| map.remove(&pid)).map(|e| e.pty)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
map.insert(id, s);
|
||||
(id, pruned)
|
||||
}
|
||||
|
||||
/// Fetch a session's `Pty` and refresh its `last_used` timestamp. Returns
|
||||
/// `None` if the id is unknown.
|
||||
pub async fn touch(&self, id: u64) -> Option<Arc<Pty>> {
|
||||
let mut map = self.inner.lock().await;
|
||||
let e = map.get_mut(&id)?;
|
||||
e.last_used = Instant::now();
|
||||
Some(e.pty.clone())
|
||||
}
|
||||
|
||||
/// Remove a session from the store, returning it (caller decides whether to
|
||||
/// `kill()` — typically not needed if the child already exited).
|
||||
pub async fn remove(&self, id: u64) -> Option<ExecSession> {
|
||||
self.inner.lock().await.remove(&id)
|
||||
}
|
||||
|
||||
/// Number of currently retained sessions (for tests/metrics).
|
||||
pub async fn len(&self) -> usize {
|
||||
self.inner.lock().await.len()
|
||||
}
|
||||
|
||||
/// True if a session id is currently retained (for tests).
|
||||
pub async fn contains(&self, id: u64) -> bool {
|
||||
self.inner.lock().await.contains_key(&id)
|
||||
}
|
||||
|
||||
/// Pick a session to evict, replicating codex `process_id_to_prune_from_meta`:
|
||||
/// protect the most-recently-used `PROTECT_RECENT`, then evict the oldest
|
||||
/// **exited** unprotected session, else the oldest unprotected session.
|
||||
fn pick_prune(map: &HashMap<u64, ExecSession>) -> Option<u64> {
|
||||
let mut meta: Vec<(u64, Instant, bool)> = map
|
||||
.values()
|
||||
.map(|e| (e.id, e.last_used, e.pty.has_exited()))
|
||||
.collect();
|
||||
if meta.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut by_recency = meta.clone();
|
||||
by_recency.sort_by(|a, b| b.1.cmp(&a.1)); // most-recent first
|
||||
let protected: HashSet<u64> = by_recency
|
||||
.iter()
|
||||
.take(PROTECT_RECENT)
|
||||
.map(|x| x.0)
|
||||
.collect();
|
||||
|
||||
meta.sort_by(|a, b| a.1.cmp(&b.1)); // oldest first (LRU)
|
||||
meta.iter()
|
||||
.find(|(id, _, exited)| !protected.contains(id) && *exited)
|
||||
.map(|x| x.0)
|
||||
.or_else(|| {
|
||||
meta.iter()
|
||||
.find(|(id, _, _)| !protected.contains(id))
|
||||
.map(|x| x.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Kill every retained session. Intended for engine shutdown so a model that
|
||||
/// spawned a pile of never-exiting REPLs doesn't leak processes.
|
||||
pub async fn terminate_all(&self) {
|
||||
let drained: Vec<ExecSession> = self
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.drain()
|
||||
.map(|(_, e)| e)
|
||||
.collect();
|
||||
for e in drained {
|
||||
e.pty.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessStore {
|
||||
/// Best-effort synchronous cleanup when the last `Arc<ProcessStore>` drops
|
||||
/// (i.e. the engine and its `ToolRegistry` are torn down). PTY children are
|
||||
/// `setsid()`'d into their own process group, so dropping the `Arc<Pty>`
|
||||
/// alone does **not** reap them — we must SIGKILL the group. `get_mut` on the
|
||||
/// `tokio::Mutex` is uncontended here (sole owner), so no async runtime is
|
||||
/// needed.
|
||||
fn drop(&mut self) {
|
||||
let map = self.inner.get_mut();
|
||||
for (_, e) in map.drain() {
|
||||
e.pty.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Incrementally read PTY output until `deadline`. If the child has exited and
|
||||
/// the output stream is closed, finishes early after draining any residue.
|
||||
///
|
||||
/// This is codex `collect_output_until_deadline` minus pause/network: it is the
|
||||
/// engine of "empty polling" — `write_stdin` with `chars=""` writes nothing and
|
||||
/// drops straight into this loop to read whatever arrived in `yield_time_ms`.
|
||||
pub async fn collect_until_deadline(
|
||||
pty: &Pty,
|
||||
mut rx: Receiver<Vec<u8>>,
|
||||
deadline: Instant,
|
||||
) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(4096);
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
if pty.has_exited() && pty.output_closed() {
|
||||
// Exited and the stream is closed: scoop up any residue and finish.
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
out.extend_from_slice(&chunk);
|
||||
}
|
||||
break;
|
||||
}
|
||||
let remaining = deadline - now;
|
||||
let closed_notify = pty.closed_notify();
|
||||
tokio::select! {
|
||||
r = rx.recv() => match r {
|
||||
Ok(chunk) => out.extend_from_slice(&chunk),
|
||||
Err(RecvError::Lagged(_)) => continue, // dropped oldest; keep reading
|
||||
Err(RecvError::Closed) => break,
|
||||
},
|
||||
_ = closed_notify.notified() => { /* re-evaluate finish on next loop */ }
|
||||
_ = tokio::time::sleep(remaining) => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pty::PtyParams;
|
||||
use crate::test_support::pty_test_helper_program;
|
||||
use std::collections::HashMap as StdHashMap;
|
||||
|
||||
/// Spawn a long-lived child that stays alive ~`secs` seconds via the
|
||||
/// cross-platform helper (replaces the unix-only `sleep`).
|
||||
fn spawn_sleep(secs: &str) -> Arc<Pty> {
|
||||
let ms = secs.parse::<u64>().unwrap_or(30) * 1000;
|
||||
Pty::spawn(PtyParams {
|
||||
program: pty_test_helper_program(),
|
||||
args: vec!["sleep".into(), ms.to_string()],
|
||||
cwd: String::new(),
|
||||
env: StdHashMap::new(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect("spawn helper sleep")
|
||||
}
|
||||
|
||||
fn session(pty: Arc<Pty>) -> ExecSession {
|
||||
ExecSession {
|
||||
id: 0,
|
||||
pty,
|
||||
command: "sleep".into(),
|
||||
tty: false,
|
||||
last_used: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ids_are_monotonic_and_lookup_works() {
|
||||
let store = ProcessStore::new();
|
||||
let (id1, p1) = store.insert(session(spawn_sleep("30"))).await;
|
||||
let (id2, p2) = store.insert(session(spawn_sleep("30"))).await;
|
||||
assert!(p1.is_none() && p2.is_none());
|
||||
assert_eq!(id2, id1 + 1);
|
||||
assert!(store.touch(id1).await.is_some());
|
||||
assert!(store.touch(9999).await.is_none());
|
||||
store.terminate_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_caps_at_max_and_protects_recent() {
|
||||
let store = ProcessStore::new();
|
||||
let mut ids = Vec::new();
|
||||
// Insert MAX_PROCESSES + 1: the +1 must trigger exactly one eviction.
|
||||
for _ in 0..=MAX_PROCESSES {
|
||||
let (id, pruned) = store.insert(session(spawn_sleep("30"))).await;
|
||||
if let Some(victim) = pruned {
|
||||
victim.kill();
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
assert_eq!(store.len().await, MAX_PROCESSES, "store must cap at MAX_PROCESSES");
|
||||
|
||||
// The 8 most-recently-inserted ids are protected and must survive.
|
||||
for id in ids.iter().rev().take(PROTECT_RECENT) {
|
||||
assert!(
|
||||
store.contains(*id).await,
|
||||
"recently-used session {id} must not be pruned"
|
||||
);
|
||||
}
|
||||
// The very first inserted (oldest, unprotected) must have been evicted.
|
||||
assert!(
|
||||
!store.contains(ids[0]).await,
|
||||
"oldest unprotected session should be evicted first"
|
||||
);
|
||||
store.terminate_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_prefers_exited_sessions() {
|
||||
// Build a meta set by hand to assert the policy without racing on exits.
|
||||
let store = ProcessStore::new();
|
||||
// One short-lived (will exit), several long-lived.
|
||||
let exiting = Pty::spawn(PtyParams {
|
||||
program: pty_test_helper_program(),
|
||||
args: vec!["exit".into(), "0".into()],
|
||||
cwd: String::new(),
|
||||
env: StdHashMap::new(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect("spawn helper exit");
|
||||
let (exited_id, _) = store.insert(session(exiting)).await;
|
||||
// Give the waiter thread time to record exit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
|
||||
// Fill to capacity with live sessions so the next insert must prune.
|
||||
let mut last_ids = Vec::new();
|
||||
for _ in 0..(MAX_PROCESSES - 1) {
|
||||
let (id, pruned) = store.insert(session(spawn_sleep("30"))).await;
|
||||
if let Some(v) = pruned {
|
||||
v.kill();
|
||||
}
|
||||
last_ids.push(id);
|
||||
}
|
||||
assert_eq!(store.len().await, MAX_PROCESSES);
|
||||
// Touch the exited session so it is NOT among the oldest, proving the
|
||||
// policy targets "exited" over "oldest". It is old by insertion but we
|
||||
// refresh last_used so recency wouldn't pick it — yet exited should.
|
||||
// (Skip the touch: leave it oldest; either way it should be pruned since
|
||||
// it is both exited and unprotected.)
|
||||
let (_new_id, pruned) = store.insert(session(spawn_sleep("30"))).await;
|
||||
let victim_killed = pruned.is_some();
|
||||
if let Some(v) = pruned {
|
||||
v.kill();
|
||||
}
|
||||
assert!(victim_killed, "insert past cap must evict someone");
|
||||
assert!(
|
||||
!store.contains(exited_id).await,
|
||||
"an exited unprotected session should be the prune target"
|
||||
);
|
||||
store.terminate_all().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Lightweight PTY wrapper for the agent layer's interactive terminal tools.
|
||||
//!
|
||||
//! Ported (and de-dependency-ed) from `nomifun-terminal::pty::PtyHandle`. The
|
||||
//! backend crate drags in db/auth/realtime/knowledge, so the agent layer must
|
||||
//! not depend on it; this is a ~120-line reimplementation that keeps only the
|
||||
//! parts `exec_command` / `write_stdin` need:
|
||||
//!
|
||||
//! - openpty + spawn a child in the PTY,
|
||||
//! - a reader thread that fans output out over a broadcast channel,
|
||||
//! - a waiter thread that is the **single source of truth for exit** (the
|
||||
//! reader's EOF must NOT be used for exit: Windows ConPTY masters never EOF
|
||||
//! when the child dies),
|
||||
//! - `write` (stdin), `kill` (process-group SIGKILL on Unix).
|
||||
//!
|
||||
//! Differences from the original `PtyHandle`: broadcast is the only output
|
||||
//! channel (no scrollback / reconnect — MVP doesn't reconnect), and exit state
|
||||
//! is exposed via atomics rather than callbacks so the collection loop can poll
|
||||
//! it without capturing closures across threads.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use tokio::sync::{Notify, broadcast};
|
||||
|
||||
/// Bounded fan-out buffer for the live output stream (in chunks). A lagging
|
||||
/// subscriber drops oldest chunks rather than stalling the reader thread; the
|
||||
/// collection loop tolerates `Lagged` by continuing.
|
||||
const OUTPUT_BROADCAST_CAP: usize = 512;
|
||||
|
||||
/// Grace period after the child exits before the exit code is published, so the
|
||||
/// reader thread can drain output still buffered in the PTY (notably on Windows,
|
||||
/// where the ConPTY master does not reach EOF on child exit).
|
||||
const EXIT_DRAIN_GRACE: Duration = Duration::from_millis(120);
|
||||
|
||||
/// Sentinel for "exit code not yet known / unavailable".
|
||||
const EXIT_UNKNOWN: i32 = i32::MIN;
|
||||
|
||||
/// Parameters for spawning a PTY-backed child.
|
||||
pub struct PtyParams {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: HashMap<String, String>,
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
}
|
||||
|
||||
/// A live PTY session: master writer + a killer split off the child, plus the
|
||||
/// output fan-out and exit/close state shared with the reader/waiter threads.
|
||||
pub struct Pty {
|
||||
/// The PTY master, retained for the life of the session. **Must not be
|
||||
/// dropped while the child is alive**: on Windows, releasing the last
|
||||
/// `MasterPty` handle closes the ConPTY, which makes a freshly-spawned child
|
||||
/// (notably `cmd.exe`) abort during init with `STATUS_DLL_INIT_FAILED`
|
||||
/// (0xC0000142) and produce no output. We keep no resize API in this MVP, so
|
||||
/// the master is otherwise inert — but it has to stay alive.
|
||||
_master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
writer: Mutex<Box<dyn Write + Send>>,
|
||||
/// A killer split from the child (via `clone_killer`) so `kill()` can signal
|
||||
/// the process while the waiter thread is parked in the blocking `wait()`.
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
out_tx: broadcast::Sender<Vec<u8>>,
|
||||
/// Child has exited (set by the waiter thread — the only source of truth).
|
||||
exited: Arc<AtomicBool>,
|
||||
/// Child exit code, or `EXIT_UNKNOWN`. Set by the waiter thread.
|
||||
exit_code: Arc<AtomicI32>,
|
||||
/// Reader reached EOF (output stream closed). Set by the reader thread.
|
||||
closed: Arc<AtomicBool>,
|
||||
/// Notifies collection loops when the output stream closes so they can wake
|
||||
/// and re-evaluate the `exited && closed` finish condition.
|
||||
closed_notify: Arc<Notify>,
|
||||
/// Direct child pid. The child is its own process-group leader (portable-pty
|
||||
/// calls `setsid()` on the slave), so this is also the process-group id used
|
||||
/// to kill the whole tree.
|
||||
pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
/// Spawn a child inside a fresh PTY. Returns immediately; output flows over
|
||||
/// the broadcast channel and exit is recorded by the waiter thread.
|
||||
pub fn spawn(p: PtyParams) -> Result<Arc<Self>, String> {
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: p.rows,
|
||||
cols: p.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("openpty: {e}"))?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(&p.program);
|
||||
for a in &p.args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
if !p.cwd.is_empty() {
|
||||
cmd.cwd(&p.cwd);
|
||||
}
|
||||
for (k, v) in &p.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("spawn '{}': {e}", p.program))?;
|
||||
// Drop the slave so the master sees EOF when the child exits (Unix).
|
||||
drop(pair.slave);
|
||||
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|e| format!("take_writer: {e}"))?;
|
||||
let mut reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("clone_reader: {e}"))?;
|
||||
// Retain the master for the life of the session (see the field docs):
|
||||
// dropping it on Windows closes the ConPTY and aborts the child's init.
|
||||
// The writer and reader were already split off above.
|
||||
let master = pair.master;
|
||||
|
||||
let pid = child.process_id();
|
||||
let killer = child.clone_killer();
|
||||
let (out_tx, _) = broadcast::channel::<Vec<u8>>(OUTPUT_BROADCAST_CAP);
|
||||
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let exit_code = Arc::new(AtomicI32::new(EXIT_UNKNOWN));
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_notify = Arc::new(Notify::new());
|
||||
|
||||
let handle = Arc::new(Pty {
|
||||
_master: Mutex::new(master),
|
||||
writer: Mutex::new(writer),
|
||||
killer: Mutex::new(killer),
|
||||
out_tx: out_tx.clone(),
|
||||
exited: exited.clone(),
|
||||
exit_code: exit_code.clone(),
|
||||
closed: closed.clone(),
|
||||
closed_notify: closed_notify.clone(),
|
||||
pid,
|
||||
});
|
||||
|
||||
// Reader thread: stream PTY output. On Windows the ConPTY master does
|
||||
// NOT reach EOF when the child exits, so this loop can outlive the child;
|
||||
// it ends on EOF (Unix / master dropped) or read error. Exit is reported
|
||||
// by the waiter thread below, NOT by this loop's EOF.
|
||||
let closed_r = closed.clone();
|
||||
let closed_notify_r = closed_notify.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break, // EOF (Unix, or master dropped)
|
||||
Ok(n) => {
|
||||
// Err just means no live receivers — harmless.
|
||||
let _ = out_tx.send(buf[..n].to_vec());
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
closed_r.store(true, Ordering::Release);
|
||||
closed_notify_r.notify_waiters();
|
||||
});
|
||||
|
||||
// Waiter thread: block directly on the child and record its exit exactly
|
||||
// once. This is the source of truth for exit — relying on the reader's
|
||||
// EOF would never fire on Windows ConPTY (the master stays open after the
|
||||
// child dies).
|
||||
std::thread::spawn(move || {
|
||||
let mut child = child;
|
||||
let code = child
|
||||
.wait()
|
||||
.ok()
|
||||
.map(|status| status.exit_code() as i32)
|
||||
.unwrap_or(EXIT_UNKNOWN);
|
||||
// Brief grace so the reader can drain output still buffered in the
|
||||
// PTY before consumers tear the session down on this signal.
|
||||
std::thread::sleep(EXIT_DRAIN_GRACE);
|
||||
exit_code.store(code, Ordering::Release);
|
||||
exited.store(true, Ordering::Release);
|
||||
closed_notify.notify_waiters();
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Write bytes to the PTY (the child's stdin).
|
||||
pub fn write(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
let mut w = self.writer.lock().map_err(|_| "pty writer poisoned")?;
|
||||
w.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
w.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Subscribe to the live output byte-stream. Each PTY chunk is delivered as a
|
||||
/// `Vec<u8>`; a lagging receiver drops oldest chunks. Subscribe **before**
|
||||
/// writing/spawning-dependent reads to avoid missing the echo.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||
self.out_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Whether the child has exited (waiter thread is the source of truth).
|
||||
pub fn has_exited(&self) -> bool {
|
||||
self.exited.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// The child's exit code, if the waiter thread has recorded it.
|
||||
pub fn exit_code(&self) -> Option<i32> {
|
||||
let c = self.exit_code.load(Ordering::Acquire);
|
||||
if c == EXIT_UNKNOWN { None } else { Some(c) }
|
||||
}
|
||||
|
||||
/// Whether the output stream has closed (reader hit EOF).
|
||||
pub fn output_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// A handle to the notifier that fires when output closes or the child exits.
|
||||
pub fn closed_notify(&self) -> Arc<Notify> {
|
||||
self.closed_notify.clone()
|
||||
}
|
||||
|
||||
/// The direct child pid (also the process-group id on Unix).
|
||||
pub fn pid(&self) -> Option<u32> {
|
||||
self.pid
|
||||
}
|
||||
|
||||
/// Terminate the child process **and its descendants**.
|
||||
///
|
||||
/// `portable-pty`'s `Child::kill()` only signals the direct child pid, which
|
||||
/// can leave grandchildren alive. The child is its own process-group leader
|
||||
/// (the slave is spawned with `setsid()`), so on Unix we additionally SIGKILL
|
||||
/// the whole process group via the negative pid to reap the entire tree. The
|
||||
/// split killer works even while the waiter thread is blocked in `wait()`.
|
||||
pub fn kill(&self) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = self.pid {
|
||||
// Negative pid → signal the process group led by `pid`.
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
if let Ok(mut killer) = self.killer.lock() {
|
||||
let _ = killer.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::pty_test_helper_program;
|
||||
use std::time::Instant;
|
||||
|
||||
fn wait_for(deadline_ms: u64, mut cond: impl FnMut() -> bool) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(deadline_ms) {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_fires_when_child_exits_on_its_own() {
|
||||
// The waiter thread (not reader EOF) must record exit.
|
||||
#[cfg(windows)]
|
||||
let (program, args) = (
|
||||
std::env::var("ComSpec").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".into()),
|
||||
vec!["/c".to_owned(), "exit".to_owned(), "0".to_owned()],
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
let (program, args) = ("sh".to_owned(), vec!["-c".to_owned(), "exit 0".to_owned()]);
|
||||
|
||||
let pty = Pty::spawn(PtyParams {
|
||||
program,
|
||||
args,
|
||||
cwd: String::new(),
|
||||
env: HashMap::new(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect("spawn");
|
||||
|
||||
assert!(
|
||||
wait_for(5000, || pty.has_exited()),
|
||||
"waiter thread must record exit when the child exits on its own"
|
||||
);
|
||||
assert_eq!(pty.exit_code(), Some(0));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_terminates_process_group() {
|
||||
// Long-lived child; the cross-platform helper sleeps instead of `sleep`.
|
||||
let pty = Pty::spawn(PtyParams {
|
||||
program: pty_test_helper_program(),
|
||||
args: vec!["sleep".into(), "60000".into()],
|
||||
cwd: String::new(),
|
||||
env: HashMap::new(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect("spawn helper sleep");
|
||||
|
||||
let pid = pty.pid().expect("pid") as i32;
|
||||
// Existence probe (signal 0).
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "child should be alive");
|
||||
|
||||
pty.kill();
|
||||
|
||||
assert!(
|
||||
wait_for(5000, || pty.has_exited()),
|
||||
"kill() should terminate the child and the waiter should record exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_echo() {
|
||||
// The helper's `echo-stdin` echoes each stdin line back on the PTY
|
||||
// (cross-platform stand-in for `cat`). Subscribe before writing.
|
||||
let pty = Pty::spawn(PtyParams {
|
||||
program: pty_test_helper_program(),
|
||||
args: vec!["echo-stdin".into()],
|
||||
cwd: String::new(),
|
||||
env: HashMap::new(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect("spawn helper echo-stdin");
|
||||
|
||||
let mut rx = pty.subscribe();
|
||||
pty.write(b"hello_pty\n").expect("write");
|
||||
|
||||
// Drain whatever arrives within a generous window.
|
||||
let mut got = Vec::new();
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(2000) {
|
||||
match rx.try_recv() {
|
||||
Ok(chunk) => got.extend_from_slice(&chunk),
|
||||
Err(broadcast::error::TryRecvError::Empty) => {
|
||||
if String::from_utf8_lossy(&got).contains("hello_pty") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::TryRecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
String::from_utf8_lossy(&got).contains("hello_pty"),
|
||||
"echo-stdin should echo back stdin, got: {:?}",
|
||||
String::from_utf8_lossy(&got)
|
||||
);
|
||||
pty.kill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::file_state::FileState;
|
||||
use nomi_types::tool::{JsonSchema, ToolImage, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::file_cache::{FileStateCache, file_mtime_ms};
|
||||
|
||||
/// Stub returned when a file has not changed since the model last read it.
|
||||
/// Saves tokens by avoiding re-sending identical content.
|
||||
const FILE_UNCHANGED_STUB: &str = "File unchanged since last read. The content from the earlier Read \
|
||||
tool_result in this conversation is still current — refer to that \
|
||||
instead of re-reading.";
|
||||
|
||||
/// Image read returns the bytes to the LLM as a ToolImage instead of the
|
||||
/// "(binary file)" stub. Capped so a huge image cannot blow up the request.
|
||||
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// MIME type for image extensions the LLM API accepts as image content blocks
|
||||
/// (jpeg/png/gif/webp). bmp/tiff keep the binary stub; svg is text and is read
|
||||
/// as source like any text file.
|
||||
fn image_media_type(path: &str) -> Option<&'static str> {
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"png" => Some("image/png"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReadTool {
|
||||
file_cache: Option<Arc<RwLock<FileStateCache>>>,
|
||||
/// Session working directory used to resolve relative `file_path` inputs
|
||||
/// (matching Grep/Glob/Bash). `None` leaves relative paths resolving
|
||||
/// against the process cwd (legacy behavior).
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ReadTool {
|
||||
/// Create a ReadTool with optional file state cache for dedup and an
|
||||
/// optional session cwd for resolving relative paths.
|
||||
///
|
||||
/// Pass `None` for `file_cache` to disable caching (all reads return full
|
||||
/// content). Pass `None` for `cwd` to keep relative paths resolving against
|
||||
/// the process working directory.
|
||||
pub fn new(file_cache: Option<Arc<RwLock<FileStateCache>>>, cwd: Option<PathBuf>) -> Self {
|
||||
Self { file_cache, cwd }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"Read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Reads a file from the local filesystem. Returns content with line numbers.\n\n\
|
||||
Usage:\n\
|
||||
- Prefer an absolute path for file_path; a relative path is resolved against the session working directory.\n\
|
||||
- By default, it reads the entire file. Use offset and limit for partial reads on large files.\n\
|
||||
- Results are returned with line numbers (1-based) followed by a tab and the line content.\n\
|
||||
- Image files (jpg/png/gif/webp) are returned as viewable images. Other binary files return \"(binary file, N bytes)\".\n\
|
||||
- This tool can only read files, not directories. To list a directory, use Bash with ls."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read (absolute preferred; a relative path resolves against the session working directory)"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Line number to start reading from (0-based)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to read"
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(raw_path) = input["file_path"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: file_path".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
// Relative paths resolve against the session working directory when one
|
||||
// was injected (matching Grep/Glob/Bash). Everything below — including
|
||||
// the cache key — uses the resolved path so dedup stays consistent.
|
||||
let resolved: String = match &self.cwd {
|
||||
Some(cwd) if !Path::new(raw_path).is_absolute() => cwd.join(raw_path).to_string_lossy().into_owned(),
|
||||
_ => raw_path.to_owned(),
|
||||
};
|
||||
let file_path = resolved.as_str();
|
||||
|
||||
let offset = input["offset"].as_u64().map(|v| v as usize);
|
||||
let limit = input["limit"].as_u64().map(|v| v as usize);
|
||||
|
||||
// Get file mtime for dedup and cache.
|
||||
let mtime_ms = file_mtime_ms(Path::new(file_path));
|
||||
|
||||
// Dedup check: if cache has the same file with matching offset/limit and mtime,
|
||||
// return a short stub instead of full content.
|
||||
if let (Some(cache_arc), Some(current_mtime)) = (&self.file_cache, mtime_ms)
|
||||
&& let Ok(mut cache) = cache_arc.write()
|
||||
&& let Some(cached) = cache.get(Path::new(file_path))
|
||||
&& cached.offset == offset
|
||||
&& cached.limit == limit
|
||||
&& cached.mtime_ms == current_mtime
|
||||
{
|
||||
return ToolResult {
|
||||
content: FILE_UNCHANGED_STUB.to_string(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Read file from disk.
|
||||
let content = match std::fs::read(file_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
content: format!("Failed to read file {}: {}", file_path, e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Image files come back as a multimodal result via ToolResult.images —
|
||||
// the same channel screenshots use. Other binaries keep the stub below.
|
||||
if let Some(media_type) = image_media_type(file_path) {
|
||||
if content.len() > MAX_IMAGE_BYTES {
|
||||
return ToolResult {
|
||||
content: format!(
|
||||
"(image file too large to attach: {} bytes, max {} bytes)",
|
||||
content.len(),
|
||||
MAX_IMAGE_BYTES
|
||||
),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&content);
|
||||
return ToolResult {
|
||||
content: format!("(image: {}, {} bytes, {})", file_path, content.len(), media_type),
|
||||
is_error: false,
|
||||
images: vec![ToolImage {
|
||||
media_type: media_type.to_string(),
|
||||
data,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if binary.
|
||||
if content.iter().take(8192).any(|&b| b == 0) {
|
||||
return ToolResult {
|
||||
content: format!("(binary file, {} bytes)", content.len()),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&content);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
let effective_offset = offset.unwrap_or(0);
|
||||
let effective_limit = limit.unwrap_or(lines.len());
|
||||
|
||||
let end = (effective_offset + effective_limit).min(lines.len());
|
||||
let slice = &lines[effective_offset.min(lines.len())..end];
|
||||
|
||||
let numbered: Vec<String> = slice
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>6}\t{}", effective_offset + i + 1, line))
|
||||
.collect();
|
||||
|
||||
let result_content = numbered.join("\n");
|
||||
|
||||
// Update cache after successful read.
|
||||
if let Some(cache_arc) = &self.file_cache
|
||||
&& let (Ok(mut cache), Some(mtime)) = (cache_arc.write(), mtime_ms)
|
||||
{
|
||||
cache.insert(
|
||||
file_path.into(),
|
||||
FileState {
|
||||
content: result_content.clone(),
|
||||
mtime_ms: mtime,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ToolResult {
|
||||
content: result_content,
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn max_result_size(&self) -> usize {
|
||||
100_000
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let path = input
|
||||
.get("file_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
format!("Read {}", path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
// -- Basic read tests (no cache) --
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_full() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||
writeln!(file, "line one").unwrap();
|
||||
writeln!(file, "line two").unwrap();
|
||||
writeln!(file, "line three").unwrap();
|
||||
drop(file);
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("1\tline one"));
|
||||
assert!(result.content.contains("2\tline two"));
|
||||
assert!(result.content.contains("3\tline three"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_with_offset_and_limit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("lines.txt");
|
||||
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||
for i in 1..=10 {
|
||||
writeln!(file, "line {}", i).unwrap();
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"offset": 2,
|
||||
"limit": 3
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
let lines: Vec<&str> = result.content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains("3\tline 3"));
|
||||
assert!(lines[1].contains("4\tline 4"));
|
||||
assert!(lines[2].contains("5\tline 5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_nonexistent_file() {
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": "/tmp/nonexistent_file_abc123.txt" });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("Failed to read file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_empty_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("empty.txt");
|
||||
std::fs::File::create(&file_path).unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_large_file_truncation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("large.txt");
|
||||
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||
for i in 1..=200 {
|
||||
writeln!(file, "line number {}", i).unwrap();
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
let lines: Vec<&str> = result.content.lines().collect();
|
||||
assert_eq!(lines.len(), 200);
|
||||
assert!(lines[0].contains("1\tline number 1"));
|
||||
assert!(lines[199].contains("200\tline number 200"));
|
||||
}
|
||||
|
||||
// -- Dedup tests (with cache) --
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_returns_stub_on_unchanged_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("dedup.txt");
|
||||
std::fs::write(&file_path, "hello\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
|
||||
// First read: full content.
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error);
|
||||
assert!(r1.content.contains("hello"));
|
||||
|
||||
// Second read: dedup stub.
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert_eq!(r2.content, FILE_UNCHANGED_STUB);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_returns_new_content_after_modification() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("modified.txt");
|
||||
std::fs::write(&file_path, "version1\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(r1.content.contains("version1"));
|
||||
|
||||
// Modify the file — ensure mtime changes.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file_path, "version2\n").unwrap();
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(r2.content.contains("version2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_different_offset_limit_returns_full() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("multi.txt");
|
||||
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||
for i in 1..=20 {
|
||||
writeln!(file, "line {}", i).unwrap();
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input1 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"offset": 0,
|
||||
"limit": 10
|
||||
});
|
||||
let r1 = tool.execute(input1).await;
|
||||
assert!(!r1.is_error);
|
||||
assert!(r1.content.contains("line 1"));
|
||||
|
||||
// Different range: should return full content, not stub.
|
||||
let input2 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"offset": 10,
|
||||
"limit": 10
|
||||
});
|
||||
let r2 = tool.execute(input2).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(r2.content.contains("line 11"));
|
||||
assert!(!r2.content.contains(FILE_UNCHANGED_STUB));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_cache_always_returns_full_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("nocache.txt");
|
||||
std::fs::write(&file_path, "data\n").unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(r1.content.contains("data"));
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(r2.content.contains("data"));
|
||||
assert_ne!(r2.content, FILE_UNCHANGED_STUB);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonexistent_file_not_cached() {
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache.clone()), None);
|
||||
|
||||
let input = json!({ "file_path": "/tmp/nonexistent_xyz_789.txt" });
|
||||
let r = tool.execute(input).await;
|
||||
assert!(r.is_error);
|
||||
|
||||
// Cache should be empty.
|
||||
let c = cache.read().unwrap();
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_empty_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("empty.txt");
|
||||
std::fs::File::create(&file_path).unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file_path.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error);
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert_eq!(r2.content, FILE_UNCHANGED_STUB);
|
||||
}
|
||||
|
||||
// -- Image branch tests --
|
||||
|
||||
/// Minimal valid PNG header bytes (enough to be a "binary" file with NUL bytes).
|
||||
const PNG_BYTES: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D];
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_png_returns_multimodal_image() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("pic.png");
|
||||
std::fs::write(&file_path, PNG_BYTES).unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let result = tool.execute(json!({ "file_path": file_path.to_str().unwrap() })).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.images.len(), 1, "png must come back as a ToolImage");
|
||||
assert_eq!(result.images[0].media_type, "image/png");
|
||||
assert!(!result.images[0].data.is_empty());
|
||||
assert!(result.content.contains("image"), "text content should describe the image");
|
||||
assert!(!result.content.contains("(binary file"), "image must not fall through to the binary stub");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_oversized_image_returns_hint_without_image() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("big.png");
|
||||
// > 5MB of zeroes
|
||||
std::fs::write(&file_path, vec![0u8; 5 * 1024 * 1024 + 1]).unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let result = tool.execute(json!({ "file_path": file_path.to_str().unwrap() })).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(result.images.is_empty());
|
||||
assert!(result.content.contains("too large"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_non_image_binary_unchanged() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("blob.bin");
|
||||
std::fs::write(&file_path, [0u8, 1, 2, 3]).unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let result = tool.execute(json!({ "file_path": file_path.to_str().unwrap() })).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(result.images.is_empty());
|
||||
assert!(result.content.contains("(binary file"));
|
||||
}
|
||||
|
||||
// -- Relative-path resolution (session cwd) --
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_resolves_against_cwd() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("rel.txt"), "cwd resolved content\n").unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, Some(dir.path().to_path_buf()));
|
||||
let result = tool.execute(json!({ "file_path": "rel.txt" })).await;
|
||||
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(result.content.contains("cwd resolved content"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_subdir_path_resolves_against_cwd() {
|
||||
// The prompt hands the model paths like "./.nomi/requirement-attachments/…" —
|
||||
// a ./-prefixed nested relative path must resolve under the session cwd.
|
||||
let dir = tempdir().unwrap();
|
||||
let sub = dir.path().join(".nomi").join("requirement-attachments");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("note.txt"), "staged attachment\n").unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, Some(dir.path().to_path_buf()));
|
||||
let result = tool
|
||||
.execute(json!({ "file_path": "./.nomi/requirement-attachments/note.txt" }))
|
||||
.await;
|
||||
|
||||
assert!(!result.is_error, "unexpected error: {}", result.content);
|
||||
assert!(result.content.contains("staged attachment"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_without_cwd_fails_gracefully() {
|
||||
// No cwd injected → legacy behavior: the relative path is tried as-is
|
||||
// against the process cwd; a missing file is a read failure, not a panic.
|
||||
let tool = ReadTool::new(None, None);
|
||||
let result = tool
|
||||
.execute(json!({ "file_path": "definitely_missing_rel_file_xyz_42.txt" }))
|
||||
.await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("Failed to read file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_dedup_uses_resolved_cache_key() {
|
||||
// The cache key must be the RESOLVED path so dedup behaves identically
|
||||
// whether the model passes the relative or the absolute form.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("rel.txt"), "hello\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), Some(dir.path().to_path_buf()));
|
||||
|
||||
let r1 = tool.execute(json!({ "file_path": "rel.txt" })).await;
|
||||
assert!(r1.content.contains("hello"));
|
||||
|
||||
// Same file via its absolute path → same cache entry → dedup stub.
|
||||
let abs = dir.path().join("rel.txt");
|
||||
let r2 = tool.execute(json!({ "file_path": abs.to_str().unwrap() })).await;
|
||||
assert!(!r2.is_error);
|
||||
assert_eq!(r2.content, FILE_UNCHANGED_STUB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use nomi_types::tool::ToolDef;
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
pub struct ToolRegistry {
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { tools: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, tool: Box<dyn Tool>) {
|
||||
self.tools.push(tool);
|
||||
}
|
||||
|
||||
/// Find a tool by name
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
|
||||
self.tools
|
||||
.iter()
|
||||
.find(|t| t.name() == name)
|
||||
.map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// Get all registered tool names
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.iter().map(|t| t.name().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Generate API tool definitions for all registered tools
|
||||
pub fn to_tool_defs(&self) -> Vec<ToolDef> {
|
||||
self.tools
|
||||
.iter()
|
||||
.map(|t| ToolDef {
|
||||
name: t.name().to_string(),
|
||||
description: t.description().to_string(),
|
||||
input_schema: t.input_schema(),
|
||||
deferred: t.is_deferred(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate API tool definitions for tools matching a predicate.
|
||||
///
|
||||
/// Used by plan mode to restrict the tool set sent to the LLM.
|
||||
pub fn to_tool_defs_filtered<F>(&self, filter: F) -> Vec<ToolDef>
|
||||
where
|
||||
F: Fn(&dyn Tool) -> bool,
|
||||
{
|
||||
self.tools
|
||||
.iter()
|
||||
.filter(|t| filter(t.as_ref()))
|
||||
.map(|t| ToolDef {
|
||||
name: t.name().to_string(),
|
||||
description: t.description().to_string(),
|
||||
input_schema: t.input_schema(),
|
||||
deferred: t.is_deferred(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Tool;
|
||||
use async_trait::async_trait;
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::ToolResult;
|
||||
|
||||
/// A minimal Tool implementation used only in tests
|
||||
struct MockTool {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
tool_category: ToolCategory,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.tool_description
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, _input: serde_json::Value) -> ToolResult {
|
||||
ToolResult::text("ok")
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
self.tool_category
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a MockTool with the given name and description
|
||||
fn make_tool(name: &str, description: &str) -> Box<MockTool> {
|
||||
Box::new(MockTool {
|
||||
tool_name: name.to_string(),
|
||||
tool_description: description.to_string(),
|
||||
tool_category: ToolCategory::Info,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_tool_with_category(
|
||||
name: &str,
|
||||
description: &str,
|
||||
category: ToolCategory,
|
||||
) -> Box<MockTool> {
|
||||
Box::new(MockTool {
|
||||
tool_name: name.to_string(),
|
||||
tool_description: description.to_string(),
|
||||
tool_category: category,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_get() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("my_tool", "does something"));
|
||||
|
||||
let found = registry.get("my_tool");
|
||||
assert!(
|
||||
found.is_some(),
|
||||
"registered tool should be retrievable by name"
|
||||
);
|
||||
assert_eq!(found.unwrap().name(), "my_tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_returns_none() {
|
||||
let registry = ToolRegistry::new();
|
||||
|
||||
let result = registry.get("ghost");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"looking up an unregistered name should return None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_names() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("alpha", "first tool"));
|
||||
registry.register(make_tool("beta", "second tool"));
|
||||
registry.register(make_tool("gamma", "third tool"));
|
||||
|
||||
let mut names = registry.tool_names();
|
||||
names.sort(); // sort for a stable assertion order
|
||||
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_tool_defs() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("tool_a", "description A"));
|
||||
registry.register(make_tool("tool_b", "description B"));
|
||||
|
||||
let defs = registry.to_tool_defs();
|
||||
assert_eq!(
|
||||
defs.len(),
|
||||
2,
|
||||
"to_tool_defs should return one entry per registered tool"
|
||||
);
|
||||
|
||||
// Collect (name, description) pairs for assertion independent of order
|
||||
let mut pairs: Vec<(&str, &str)> = defs
|
||||
.iter()
|
||||
.map(|d| (d.name.as_str(), d.description.as_str()))
|
||||
.collect();
|
||||
pairs.sort();
|
||||
|
||||
assert_eq!(pairs[0], ("tool_a", "description A"));
|
||||
assert_eq!(pairs[1], ("tool_b", "description B"));
|
||||
|
||||
// Verify the input_schema field is populated correctly
|
||||
let expected_schema = serde_json::json!({"type": "object"});
|
||||
for def in &defs {
|
||||
assert_eq!(def.input_schema, expected_schema);
|
||||
}
|
||||
}
|
||||
|
||||
// --- to_tool_defs_filtered tests ---
|
||||
|
||||
#[test]
|
||||
fn filtered_by_category_returns_matching_tools() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool_with_category(
|
||||
"Read",
|
||||
"read files",
|
||||
ToolCategory::Info,
|
||||
));
|
||||
registry.register(make_tool_with_category(
|
||||
"Write",
|
||||
"write files",
|
||||
ToolCategory::Edit,
|
||||
));
|
||||
registry.register(make_tool_with_category(
|
||||
"Bash",
|
||||
"run commands",
|
||||
ToolCategory::Exec,
|
||||
));
|
||||
registry.register(make_tool_with_category(
|
||||
"ExitPlanMode",
|
||||
"exit plan mode",
|
||||
ToolCategory::Info,
|
||||
));
|
||||
|
||||
let defs = registry.to_tool_defs_filtered(|t| t.category() == ToolCategory::Info);
|
||||
|
||||
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
|
||||
assert!(names.contains(&"Read"));
|
||||
assert!(names.contains(&"ExitPlanMode"));
|
||||
assert!(!names.contains(&"Write"));
|
||||
assert!(!names.contains(&"Bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_by_name_excludes_specific_tool() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("alpha", "first"));
|
||||
registry.register(make_tool("beta", "second"));
|
||||
registry.register(make_tool("gamma", "third"));
|
||||
|
||||
let defs = registry.to_tool_defs_filtered(|t| t.name() != "beta");
|
||||
|
||||
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"alpha"));
|
||||
assert!(names.contains(&"gamma"));
|
||||
assert!(!names.contains(&"beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_accept_all_matches_to_tool_defs() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("a", "tool a"));
|
||||
registry.register(make_tool("b", "tool b"));
|
||||
|
||||
let all = registry.to_tool_defs();
|
||||
let filtered = registry.to_tool_defs_filtered(|_| true);
|
||||
|
||||
assert_eq!(all.len(), filtered.len());
|
||||
for (a, f) in all.iter().zip(filtered.iter()) {
|
||||
assert_eq!(a.name, f.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_reject_all_returns_empty() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("a", "tool a"));
|
||||
|
||||
let defs = registry.to_tool_defs_filtered(|_| false);
|
||||
assert!(defs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_empty_registry_returns_empty() {
|
||||
let registry = ToolRegistry::new();
|
||||
let defs = registry.to_tool_defs_filtered(|_| true);
|
||||
assert!(defs.is_empty());
|
||||
}
|
||||
|
||||
// --- deferred flag tests ---
|
||||
|
||||
/// A minimal Tool that overrides is_deferred() to return true
|
||||
struct DeferredMockTool {
|
||||
tool_name: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for DeferredMockTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"a deferred tool"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {"x": {"type": "string"}}})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_deferred(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, _input: serde_json::Value) -> ToolResult {
|
||||
ToolResult::text("ok")
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_tool_defs_includes_deferred_flag() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(make_tool("core_tool", "a core tool"));
|
||||
let defs = registry.to_tool_defs();
|
||||
assert!(!defs[0].deferred, "default tools should not be deferred");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_tool_defs_deferred_tool_flagged() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Box::new(DeferredMockTool {
|
||||
tool_name: "lazy_tool".to_string(),
|
||||
}));
|
||||
let defs = registry.to_tool_defs();
|
||||
assert!(defs[0].deferred, "deferred tool should have deferred=true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! macOS Seatbelt write-containment sandbox for the `Bash` tool (design §3.6
|
||||
//! "Bash/Edit/Write 执行沙箱(macOS Seatbelt 优先)").
|
||||
//!
|
||||
//! Opt-in (`tools.bash_sandbox`, default off). When enabled on macOS, shell
|
||||
//! commands run under `sandbox-exec` with a profile that **denies all
|
||||
//! file-writes except** to the workspace root(s), the system temp dirs, and the
|
||||
//! standard write devices (`/dev/null`, stdout/stderr/tty, `/dev/fd`). Reads,
|
||||
//! network and exec are left allowed.
|
||||
//!
|
||||
//! # Why this complements `path_guard`
|
||||
//!
|
||||
//! `path_guard` only constrains *our own* Write/Edit/ApplyPatch tools.
|
||||
//! Seatbelt constrains **every write syscall of every subprocess** a Bash
|
||||
//! command spawns (a `make install` into `/usr/local`, a script touching
|
||||
//! `~/.bashrc`), enforced by the kernel.
|
||||
//!
|
||||
//! # Honest scope
|
||||
//!
|
||||
//! This protects the broader filesystem from *writes* outside the workspace +
|
||||
//! temp. It is NOT a full adversarial sandbox: network and process execution
|
||||
//! remain allowed, so it guards against accidental/buggy damage, not a
|
||||
//! determined adversary. Process hardening (ptrace/exec restrictions) is a
|
||||
//! separate concern.
|
||||
//!
|
||||
//! # Two gotchas this module gets right (both caught by running it on macOS)
|
||||
//!
|
||||
//! 1. **Canonical paths**: `/tmp` is a symlink to `/private/tmp`; Seatbelt
|
||||
//! `subpath` matches the kernel's canonical path, so roots are canonicalised.
|
||||
//! 2. **Device writes**: a bare `(deny file-write*)` blocks `> /dev/null`
|
||||
//! redirects, breaking ordinary commands — the standard devices are
|
||||
//! explicitly re-allowed.
|
||||
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Whether the sandbox can be used (macOS + `sandbox-exec` present).
|
||||
pub fn is_supported() -> bool {
|
||||
Path::new("/usr/bin/sandbox-exec").exists()
|
||||
}
|
||||
|
||||
/// Escape a path for embedding inside a Seatbelt profile string literal.
|
||||
fn escape(path: &str) -> String {
|
||||
path.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
/// Canonicalise a path for Seatbelt (resolve `/tmp`→`/private/tmp`, symlinks,
|
||||
/// `..`). Falls back to the original on failure (e.g. not-yet-existing).
|
||||
fn canonical(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Build a Seatbelt profile that allows everything by default, denies all
|
||||
/// file-writes, then re-allows writes under each canonicalised root plus the
|
||||
/// system temp dirs and the standard write devices.
|
||||
pub fn write_sandbox_profile(write_roots: &[PathBuf]) -> String {
|
||||
let mut allowed: Vec<PathBuf> = Vec::new();
|
||||
for r in write_roots {
|
||||
allowed.push(canonical(r));
|
||||
}
|
||||
// System temp dirs (canonicalised) — builds/tools need a scratch space.
|
||||
// Kept tight: the per-user TMPDIR and /tmp, NOT the broad /var/folders tree.
|
||||
if let Ok(tmp) = std::env::var("TMPDIR") {
|
||||
allowed.push(canonical(Path::new(&tmp)));
|
||||
}
|
||||
allowed.push(canonical(Path::new("/private/tmp")));
|
||||
|
||||
let mut profile = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
|
||||
|
||||
if !allowed.is_empty() {
|
||||
profile.push_str("(allow file-write*\n");
|
||||
for p in &allowed {
|
||||
profile.push_str(&format!(" (subpath \"{}\")\n", escape(&p.to_string_lossy())));
|
||||
}
|
||||
profile.push_str(")\n");
|
||||
}
|
||||
|
||||
// Standard write devices, or ordinary `>/dev/null` redirects break.
|
||||
profile.push_str(
|
||||
"(allow file-write*\n \
|
||||
(literal \"/dev/null\")\n \
|
||||
(literal \"/dev/stdout\")\n \
|
||||
(literal \"/dev/stderr\")\n \
|
||||
(literal \"/dev/tty\")\n \
|
||||
(literal \"/dev/dtracehelper\")\n \
|
||||
(subpath \"/dev/fd\")\n)\n",
|
||||
);
|
||||
profile
|
||||
}
|
||||
|
||||
/// Build the argv that runs `inner_argv` under the sandbox: `sandbox-exec -p
|
||||
/// <profile> <inner_argv...>`.
|
||||
pub fn wrap_command(profile: &str, inner_argv: &[&str]) -> Vec<String> {
|
||||
let mut argv = vec!["/usr/bin/sandbox-exec".to_string(), "-p".to_string(), profile.to_string()];
|
||||
argv.extend(inner_argv.iter().map(|s| s.to_string()));
|
||||
argv
|
||||
}
|
||||
|
||||
/// Dynamic-linker injection env vars that let an inherited environment load
|
||||
/// arbitrary code into a child — stripped from sandboxed subprocesses so a
|
||||
/// command cannot be subverted via the agent's inherited env (§3.6 进程加固).
|
||||
pub const DANGEROUS_ENV_VARS: &[&str] = &[
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"DYLD_FRAMEWORK_PATH",
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
];
|
||||
|
||||
/// Remove the dynamic-linker injection vars from a command's environment.
|
||||
pub fn harden_env(cmd: &mut tokio::process::Command) {
|
||||
for var in DANGEROUS_ENV_VARS {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn profile_lists_canonical_roots_and_devices() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let profile = write_sandbox_profile(&[dir.path().to_path_buf()]);
|
||||
assert!(profile.contains("(deny file-write*)"));
|
||||
// The root appears as a canonical subpath.
|
||||
let canon = dir.path().canonicalize().unwrap();
|
||||
assert!(
|
||||
profile.contains(&format!("(subpath \"{}\")", canon.to_string_lossy())),
|
||||
"profile must allow the canonical root, got:\n{profile}"
|
||||
);
|
||||
assert!(profile.contains("/dev/null"), "must re-allow /dev/null");
|
||||
}
|
||||
|
||||
// Enforcement is verifiable on this macOS host via the real sandbox-exec.
|
||||
#[test]
|
||||
fn sandbox_blocks_out_of_root_writes_but_allows_in_root() {
|
||||
if !is_supported() {
|
||||
return; // sandbox-exec unavailable — skip
|
||||
}
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let canon_root = root.path().canonicalize().unwrap();
|
||||
let profile = write_sandbox_profile(&[canon_root.clone()]);
|
||||
|
||||
// In-root write succeeds.
|
||||
let inside = canon_root.join("ok.txt");
|
||||
let argv = wrap_command(
|
||||
&profile,
|
||||
&["/bin/sh", "-c", &format!("echo hi > {}", inside.display())],
|
||||
);
|
||||
let status = Command::new(&argv[0]).args(&argv[1..]).status().unwrap();
|
||||
assert!(status.success(), "in-root write should succeed");
|
||||
assert_eq!(std::fs::read_to_string(&inside).unwrap().trim(), "hi");
|
||||
|
||||
// Out-of-root write is blocked by the kernel. Target $HOME (not temp,
|
||||
// which the profile intentionally allows). Cleaned up either way.
|
||||
let home = std::env::var("HOME").expect("HOME set");
|
||||
let outside = Path::new(&home).join(".nomi_sandbox_escape_test.txt");
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
let argv = wrap_command(
|
||||
&profile,
|
||||
&["/bin/sh", "-c", &format!("echo hi > {}", outside.display())],
|
||||
);
|
||||
let _ = Command::new(&argv[0]).args(&argv[1..]).status().unwrap();
|
||||
let escaped = outside.exists();
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
assert!(!escaped, "out-of-root write (to $HOME) must be blocked by the sandbox");
|
||||
|
||||
// A normal command that redirects to /dev/null still works.
|
||||
let argv = wrap_command(&profile, &["/bin/sh", "-c", "echo hi > /dev/null && echo OK"]);
|
||||
let out = Command::new(&argv[0]).args(&argv[1..]).output().unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("OK"),
|
||||
"redirect to /dev/null must work under the sandbox"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn harden_env_strips_injection_vars() {
|
||||
// Set an injection var ON THE COMMAND, harden it, and confirm the child
|
||||
// does not see it (no global env mutation needed).
|
||||
let mut cmd = tokio::process::Command::new("/bin/sh");
|
||||
cmd.env("DYLD_INSERT_LIBRARIES", "/tmp/evil.dylib")
|
||||
.arg("-c")
|
||||
.arg("printf '%s' \"$DYLD_INSERT_LIBRARIES\"");
|
||||
harden_env(&mut cmd);
|
||||
let out = cmd.output().await.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).is_empty(),
|
||||
"DYLD_INSERT_LIBRARIES must be stripped from the child env"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Test-only helpers shared by the PTY/process unit tests.
|
||||
//!
|
||||
//! These tests must spawn a cross-platform child process (the `pty_test_helper`
|
||||
//! binary built alongside this crate) instead of unix-only programs. Because the
|
||||
//! tests live in `src/` (unit tests), `CARGO_BIN_EXE_pty_test_helper` is NOT
|
||||
//! available — that env var is only injected for integration tests under
|
||||
//! `tests/`. So we probe for the bin (see [`pty_test_helper_bin`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Absolute path to the `pty_test_helper` binary built with this crate.
|
||||
///
|
||||
/// Discovery must survive this repo's split build layout: `.cargo/config.toml`
|
||||
/// sets `build-dir = {workspace-root}/build.noindex`, so the unit-test RUNNER
|
||||
/// exe lives under `build.noindex/<profile>/deps/`, while the `[[bin]]` artifact
|
||||
/// is hard-linked into the default target dir `target/<profile>/`. `CARGO_BIN_EXE_*`
|
||||
/// is unavailable to `src/` unit tests, so we probe candidate locations and take
|
||||
/// the first that exists (standard layout, split build-dir, and a manifest-root
|
||||
/// derivation as a backstop).
|
||||
pub(crate) fn pty_test_helper_bin() -> PathBuf {
|
||||
let bin_name = if cfg!(windows) { "pty_test_helper.exe" } else { "pty_test_helper" };
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
// current_exe = .../<profile>/deps/<test-runner>.exe → profile_dir = .../<profile>
|
||||
let profile_dir = exe
|
||||
.parent()
|
||||
.and_then(|deps| if deps.ends_with("deps") { deps.parent() } else { Some(deps) })
|
||||
.expect("profile dir")
|
||||
.to_path_buf();
|
||||
let profile = profile_dir.file_name().and_then(|s| s.to_str()).unwrap_or("debug").to_string();
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
// 1) Standard cargo: bin sits alongside the profile dir (target/<profile>/bin).
|
||||
candidates.push(profile_dir.join(bin_name));
|
||||
// 2) Split build-dir: the runner is under build.noindex/<profile> but the bin
|
||||
// artifact lands in the sibling target/<profile>. Map the path across.
|
||||
if let Some(s) = profile_dir.to_str() {
|
||||
if s.contains("build.noindex") {
|
||||
candidates.push(PathBuf::from(s.replacen("build.noindex", "target", 1)).join(bin_name));
|
||||
}
|
||||
}
|
||||
// 3) Backstop: derive from the workspace root via CARGO_MANIFEST_DIR
|
||||
// (crates/agent/nomi-tools → agent → crates → <root>).
|
||||
if let Some(root) = PathBuf::from(env!("CARGO_MANIFEST_DIR")).ancestors().nth(3) {
|
||||
candidates.push(root.join("target").join(&profile).join(bin_name));
|
||||
}
|
||||
candidates
|
||||
.iter()
|
||||
.find(|c| c.exists())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("pty_test_helper binary not found; tried: {candidates:?}"))
|
||||
}
|
||||
|
||||
/// The helper path as a `String`, for use as a `PtyParams.program`.
|
||||
pub(crate) fn pty_test_helper_program() -> String {
|
||||
pty_test_helper_bin().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// A shell command line that runs the helper with `subcommand` through the
|
||||
/// platform shell (`cmd /C` / `sh -c`), for the `exec_command` / `write_stdin`
|
||||
/// tools, which always wrap their `cmd` in the login shell.
|
||||
///
|
||||
/// Quoting differs by platform because of how `portable-pty` builds the child
|
||||
/// command line on each OS:
|
||||
///
|
||||
/// - **Unix (`sh -c`)**: the helper path is double-quoted so spaces survive the
|
||||
/// shell word-split. `sh` parses quotes correctly.
|
||||
///
|
||||
/// - **Windows (`cmd /C`)**: the path is emitted **unquoted**. `portable-pty`'s
|
||||
/// `CommandBuilder` argv-quotes each arg using MSVCRT rules — any arg
|
||||
/// containing a quote gets its `"` rewritten to `\"`. But `cmd /C` does NOT
|
||||
/// understand argv `\"` escaping; it would treat `\"C:\path\helper.exe\"` as a
|
||||
/// literal (quotes-in-name) program and fail with "is not recognized as an
|
||||
/// internal or external command". With no embedded quotes, `portable-pty`
|
||||
/// wraps the whole single arg in one outer quote pair (no inner escapes) and
|
||||
/// `cmd /C` strips that pair cleanly, yielding a parseable command line.
|
||||
/// This relies on the helper path containing **no spaces** — true for this
|
||||
/// repo's controlled build layout (`target` / `build.noindex` under the
|
||||
/// workspace root). A spaced path cannot be expressed correctly through the
|
||||
/// `cmd /C` + `portable-pty` argv-escaping combination from a single command
|
||||
/// string, so we accept that constraint here rather than mangle the quotes.
|
||||
pub(crate) fn pty_test_helper_shell_cmd(subcommand: &str) -> String {
|
||||
let prog = pty_test_helper_program();
|
||||
if cfg!(windows) {
|
||||
debug_assert!(
|
||||
!prog.contains(' '),
|
||||
"Windows shell-wrapped helper path must be space-free (cmd /C + \
|
||||
portable-pty cannot carry a quoted path); got: {prog}"
|
||||
);
|
||||
format!("{prog} {subcommand}")
|
||||
} else {
|
||||
format!("\"{prog}\" {subcommand}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolDef, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
/// Built-in tool that searches for deferred tools and loads their full schema.
|
||||
/// Core tool (never deferred itself) — always available to the LLM.
|
||||
pub struct ToolSearchTool {
|
||||
/// Snapshot of all tool definitions (taken at construction time).
|
||||
tool_defs: Vec<ToolDef>,
|
||||
}
|
||||
|
||||
impl ToolSearchTool {
|
||||
pub fn new(tool_defs: Vec<ToolDef>) -> Self {
|
||||
Self { tool_defs }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ToolSearchTool {
|
||||
fn name(&self) -> &str {
|
||||
"ToolSearch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for deferred tools and load their full schema. \
|
||||
Use this before calling any deferred tool."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Tool name or keyword to search for"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let query = input["query"].as_str().unwrap_or("");
|
||||
if query.is_empty() {
|
||||
return ToolResult {
|
||||
content: "Error: query is required".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let matches: Vec<Value> = self
|
||||
.tool_defs
|
||||
.iter()
|
||||
.filter(|d| d.deferred)
|
||||
.filter(|d| {
|
||||
d.name.to_lowercase().contains(&query_lower)
|
||||
|| d.description.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.map(|d| {
|
||||
json!({
|
||||
"name": d.name,
|
||||
"description": d.description,
|
||||
"parameters": d.input_schema
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return ToolResult {
|
||||
content: format!("No deferred tools matching \"{}\" found.", query),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
ToolResult {
|
||||
content: serde_json::to_string_pretty(&matches).unwrap_or_default(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_tool_defs() -> Vec<ToolDef> {
|
||||
vec![
|
||||
ToolDef {
|
||||
name: "Read".into(),
|
||||
description: "Read a file".into(),
|
||||
input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}),
|
||||
deferred: false,
|
||||
},
|
||||
ToolDef {
|
||||
name: "SpawnTool".into(),
|
||||
description: "Spawn sub-agents".into(),
|
||||
input_schema: json!({"type": "object", "properties": {"agents": {"type": "array"}}}),
|
||||
deferred: true,
|
||||
},
|
||||
ToolDef {
|
||||
name: "EnterPlanMode".into(),
|
||||
description: "Enter plan mode".into(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
deferred: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_by_exact_name() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": "SpawnTool"})).await;
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("SpawnTool"));
|
||||
assert!(result.content.contains("Spawn sub-agents"));
|
||||
assert!(result.content.contains("parameters"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_case_insensitive() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": "spawntool"})).await;
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("SpawnTool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_by_description_keyword() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": "plan"})).await;
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("EnterPlanMode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_excludes_non_deferred() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": "Read"})).await;
|
||||
// "Read" is not deferred, should not appear in results
|
||||
assert!(
|
||||
!result.content.contains("\"name\": \"Read\"")
|
||||
|| result.content.contains("No deferred tools")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_no_match() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": "nonexistent"})).await;
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("No deferred tools"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_empty_query_returns_error() {
|
||||
let tool = ToolSearchTool::new(build_tool_defs());
|
||||
let result = tool.execute(json!({"query": ""})).await;
|
||||
assert!(result.is_error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
/// Single step status. snake_case aligns with codex and the frontend
|
||||
/// `entry.status` (`pending`/`in_progress`/`completed`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/// One plan step (argument). `step` is the step text; it is normalized to
|
||||
/// `content` for the frontend plan renderer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanItemArg {
|
||||
pub step: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
/// `update_plan` arguments — a stateless full snapshot of the plan.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdatePlanArgs {
|
||||
#[serde(default)]
|
||||
pub explanation: Option<String>,
|
||||
pub plan: Vec<PlanItemArg>,
|
||||
}
|
||||
|
||||
/// codex-style todo/checklist tool. Stateless: the model submits the full step
|
||||
/// list every call.
|
||||
///
|
||||
/// This is a different concept from nomi's Plan Mode
|
||||
/// (`EnterPlanMode`/`ExitPlanMode`), which is a *mode* that restricts the tool
|
||||
/// allow-list. `update_plan` is a *progress declaration* tool.
|
||||
pub struct UpdatePlanTool;
|
||||
|
||||
impl UpdatePlanTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UpdatePlanTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UpdatePlanTool {
|
||||
fn name(&self) -> &str {
|
||||
"update_plan"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Update the task plan (a todo checklist shown to the user). \
|
||||
Provide an optional `explanation` and a full `plan`: the complete list of steps, \
|
||||
each with a one-line `step` and a `status` of pending, in_progress, or completed. \
|
||||
This is a stateless full snapshot — send the entire current plan every time, not a diff. \
|
||||
There should be exactly one in_progress step until all are completed; mark a step \
|
||||
completed before starting the next. Use it for non-trivial multi-step work; do not use \
|
||||
it for simple single-step queries, and do not pad with filler steps. After calling it, \
|
||||
do not repeat the full plan in your reply — just note what changed and the next step."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": {
|
||||
"type": "string",
|
||||
"description": "Optional rationale for this plan update."
|
||||
},
|
||||
"plan": {
|
||||
"type": "array",
|
||||
"description": "The full list of plan steps (complete snapshot).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"step": { "type": "string", "description": "Task step text (one short line)." },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed"],
|
||||
"description": "Step status."
|
||||
}
|
||||
},
|
||||
"required": ["step", "status"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["plan"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
// Pure declaration, no side effects — safe to run concurrently.
|
||||
true
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Info
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
// 1) Parse arguments.
|
||||
let args: UpdatePlanArgs = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return ToolResult::error(format!("update_plan: invalid arguments: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
if args.plan.is_empty() {
|
||||
return ToolResult::error("update_plan: `plan` must contain at least one step.");
|
||||
}
|
||||
|
||||
// 2) Soft constraint: at most one in_progress. More than one does NOT
|
||||
// fail (avoids breaking the agent loop, matching codex) — we just
|
||||
// warn so the model self-corrects.
|
||||
let in_progress = args
|
||||
.plan
|
||||
.iter()
|
||||
.filter(|p| p.status == StepStatus::InProgress)
|
||||
.count();
|
||||
|
||||
// 3) Normalize into frontend entry shape: { content, status }
|
||||
// (note step -> content).
|
||||
let entries: Vec<Value> = args
|
||||
.plan
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"content": p.step,
|
||||
"status": match p.status {
|
||||
StepStatus::Pending => "pending",
|
||||
StepStatus::InProgress => "in_progress",
|
||||
StepStatus::Completed => "completed",
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 4) Encode the structured snapshot into content (JSON). The backend
|
||||
// bridge layer parses this to emit a Plan event; the same string is
|
||||
// the (compact) tool_result returned to the model.
|
||||
let payload = json!({
|
||||
"kind": "plan_update",
|
||||
"explanation": args.explanation,
|
||||
"entries": entries,
|
||||
});
|
||||
let content = serde_json::to_string(&payload)
|
||||
.unwrap_or_else(|_| "{\"kind\":\"plan_update\",\"entries\":[]}".to_string());
|
||||
|
||||
if in_progress > 1 {
|
||||
let warn = format!(
|
||||
"[note] {in_progress} steps are in_progress; convention is exactly one. Plan rendered as submitted.\n"
|
||||
);
|
||||
return ToolResult::text(format!("{warn}{content}"));
|
||||
}
|
||||
|
||||
ToolResult::text(content)
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let n = input
|
||||
.get("plan")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
format!("Update plan ({n} steps)")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn name_and_category() {
|
||||
let t = UpdatePlanTool::new();
|
||||
assert_eq!(t.name(), "update_plan");
|
||||
assert!(matches!(t.category(), ToolCategory::Info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_requires_plan() {
|
||||
let s = UpdatePlanTool::new().input_schema();
|
||||
let req = s["required"].as_array().unwrap();
|
||||
assert!(req.iter().any(|v| v == "plan"));
|
||||
let item_req = s["properties"]["plan"]["items"]["required"].as_array().unwrap();
|
||||
assert!(item_req.iter().any(|v| v == "step"));
|
||||
assert!(item_req.iter().any(|v| v == "status"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rejects_empty_plan() {
|
||||
let r = UpdatePlanTool::new().execute(json!({ "plan": [] })).await;
|
||||
assert!(r.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rejects_bad_args() {
|
||||
let r = UpdatePlanTool::new().execute(json!({ "plan": "nope" })).await;
|
||||
assert!(r.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_normalizes_step_to_content() {
|
||||
let r = UpdatePlanTool::new()
|
||||
.execute(json!({
|
||||
"plan": [{ "step": "Read code", "status": "in_progress" }]
|
||||
}))
|
||||
.await;
|
||||
assert!(!r.is_error);
|
||||
let start = r.content.find('{').unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&r.content[start..]).unwrap();
|
||||
assert_eq!(v["kind"], "plan_update");
|
||||
assert_eq!(v["entries"][0]["content"], "Read code");
|
||||
assert_eq!(v["entries"][0]["status"], "in_progress");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_one_in_progress_no_warning() {
|
||||
let r = UpdatePlanTool::new()
|
||||
.execute(json!({
|
||||
"plan": [
|
||||
{ "step": "a", "status": "completed" },
|
||||
{ "step": "b", "status": "in_progress" },
|
||||
{ "step": "c", "status": "pending" }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
assert!(!r.is_error);
|
||||
assert!(!r.content.contains("[note]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_multi_in_progress_warns_but_succeeds() {
|
||||
let r = UpdatePlanTool::new()
|
||||
.execute(json!({
|
||||
"plan": [
|
||||
{ "step": "a", "status": "in_progress" },
|
||||
{ "step": "b", "status": "in_progress" }
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
assert!(!r.is_error);
|
||||
assert!(r.content.contains("[note]"));
|
||||
let start = r.content.find('{').unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&r.content[start..]).unwrap();
|
||||
assert_eq!(v["entries"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_carries_explanation() {
|
||||
let r = UpdatePlanTool::new()
|
||||
.execute(json!({
|
||||
"explanation": "re-scoping",
|
||||
"plan": [{ "step": "x", "status": "pending" }]
|
||||
}))
|
||||
.await;
|
||||
let start = r.content.find('{').unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&r.content[start..]).unwrap();
|
||||
assert_eq!(v["explanation"], "re-scoping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_reports_step_count() {
|
||||
let t = UpdatePlanTool::new();
|
||||
let d = t.describe(&json!({ "plan": [ {"step":"a","status":"pending"}, {"step":"b","status":"pending"} ] }));
|
||||
assert!(d.contains('2'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Git worktree isolation for parallel editing sub-agents (design §3.4
|
||||
//! "worktree 隔离": 并行编辑子 agent 用临时 worktree,校验后回并).
|
||||
//!
|
||||
//! When several `implementer` sub-agents edit files concurrently they can
|
||||
//! clobber one another in the shared tree. Running each in its own detached git
|
||||
//! worktree isolates their edits; the parent collects each one's diff (returned
|
||||
//! as a unified patch) and decides what to apply — no auto-merge, so there is no
|
||||
//! merge-conflict resolution to get wrong.
|
||||
//!
|
||||
//! Opt-in and additive: only used when a Spawn fan-out requests isolation AND
|
||||
//! the workspace is a git repo. The worktree is removed on drop.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Monotonic suffix so concurrent worktrees get distinct dir names without
|
||||
/// needing a clock or RNG (both unavailable / nondeterministic).
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// True if `root` is inside a git working tree.
|
||||
pub fn is_git_repo(root: &Path) -> bool {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A detached git worktree of `repo`, removed on drop.
|
||||
pub struct Worktree {
|
||||
repo: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Worktree {
|
||||
/// Create a detached worktree of `repo` at HEAD. Errors if `repo` is not a
|
||||
/// git repo or `git worktree add` fails.
|
||||
pub fn create(repo: &Path) -> Result<Self, String> {
|
||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir_name = format!(".nomi-worktree-{}-{}", std::process::id(), seq);
|
||||
// Place the worktree as a sibling of the repo so it is not itself scanned
|
||||
// as part of the repo tree.
|
||||
let parent = repo.parent().unwrap_or(repo);
|
||||
let path = parent.join(&dir_name);
|
||||
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["worktree", "add", "--detach"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.map_err(|e| format!("git worktree add failed to spawn: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"git worktree add failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(Self { repo: repo.to_path_buf(), path })
|
||||
}
|
||||
|
||||
/// The worktree's path (use as the sub-agent's cwd).
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Capture all changes made in the worktree (new + modified files) as a
|
||||
/// unified diff. Stages everything first so untracked files are included.
|
||||
pub fn capture_diff(&self) -> Result<String, String> {
|
||||
let add = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&self.path)
|
||||
.args(["add", "-A"])
|
||||
.output()
|
||||
.map_err(|e| format!("git add failed to spawn: {e}"))?;
|
||||
if !add.status.success() {
|
||||
return Err(format!("git add failed: {}", String::from_utf8_lossy(&add.stderr).trim()));
|
||||
}
|
||||
let diff = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&self.path)
|
||||
.args(["diff", "--cached"])
|
||||
.output()
|
||||
.map_err(|e| format!("git diff failed to spawn: {e}"))?;
|
||||
if !diff.status.success() {
|
||||
return Err(format!("git diff failed: {}", String::from_utf8_lossy(&diff.stderr).trim()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&diff.stdout).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Worktree {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort removal; --force discards the worktree's uncommitted edits
|
||||
// (the parent already captured the diff it cares about).
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&self.repo)
|
||||
.args(["worktree", "remove", "--force"])
|
||||
.arg(&self.path)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn git(args: &[&str], cwd: &Path) {
|
||||
let out = Command::new("git").arg("-C").arg(cwd).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
/// Init a repo with one committed file. Returns the repo dir (kept alive by
|
||||
/// the returned TempDir).
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path();
|
||||
git(&["init", "-q"], p);
|
||||
git(&["config", "user.email", "t@t"], p);
|
||||
git(&["config", "user.name", "t"], p);
|
||||
std::fs::write(p.join("a.txt"), "original\n").unwrap();
|
||||
git(&["add", "-A"], p);
|
||||
git(&["commit", "-q", "-m", "init"], p);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repo_detects_repo_and_non_repo() {
|
||||
let repo = init_repo();
|
||||
assert!(is_git_repo(repo.path()));
|
||||
let plain = tempfile::tempdir().unwrap();
|
||||
assert!(!is_git_repo(plain.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_isolates_edits_and_captures_diff() {
|
||||
let repo = init_repo();
|
||||
let wt = Worktree::create(repo.path()).expect("create worktree");
|
||||
assert!(wt.path().exists(), "worktree dir should exist");
|
||||
|
||||
// Edit an existing file and add a new one inside the worktree.
|
||||
std::fs::write(wt.path().join("a.txt"), "changed\n").unwrap();
|
||||
std::fs::write(wt.path().join("new.txt"), "brand new\n").unwrap();
|
||||
|
||||
// The main tree is untouched (isolation).
|
||||
assert_eq!(std::fs::read_to_string(repo.path().join("a.txt")).unwrap(), "original\n");
|
||||
assert!(!repo.path().join("new.txt").exists());
|
||||
|
||||
let diff = wt.capture_diff().expect("diff");
|
||||
assert!(diff.contains("a.txt"), "diff mentions the edited file:\n{diff}");
|
||||
assert!(diff.contains("changed"), "diff shows the change:\n{diff}");
|
||||
assert!(diff.contains("new.txt"), "diff includes the new file:\n{diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_is_removed_on_drop() {
|
||||
let repo = init_repo();
|
||||
let path = {
|
||||
let wt = Worktree::create(repo.path()).expect("create");
|
||||
wt.path().to_path_buf()
|
||||
}; // dropped here
|
||||
assert!(!path.exists(), "worktree dir must be removed on drop");
|
||||
// And git no longer lists it.
|
||||
let list = Command::new("git").arg("-C").arg(repo.path()).args(["worktree", "list"]).output().unwrap();
|
||||
let listing = String::from_utf8_lossy(&list.stdout);
|
||||
assert!(!listing.contains(path.to_string_lossy().as_ref()), "git must not still list the worktree");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::file_cache::{FileStateCache, update_cache_after_write};
|
||||
|
||||
pub struct WriteTool {
|
||||
file_cache: Option<Arc<RwLock<FileStateCache>>>,
|
||||
/// Optional containment root; when set, writes outside it are rejected.
|
||||
write_root: Option<std::path::PathBuf>,
|
||||
/// Session working directory used to resolve relative `file_path` inputs
|
||||
/// (matching ReadTool / Grep / Glob / Bash). `None` leaves relative paths
|
||||
/// resolving against the process cwd (legacy behavior).
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl WriteTool {
|
||||
/// Create a WriteTool with optional file state cache.
|
||||
///
|
||||
/// When cache is `Some`, the tool updates the cache after each successful
|
||||
/// write so that subsequent Edit/Read calls see the latest content and mtime.
|
||||
///
|
||||
/// No "must Read first" guard: Write is intended for creating new files
|
||||
/// or complete rewrites.
|
||||
///
|
||||
/// Pass `None` to disable cache integration (legacy behavior).
|
||||
pub fn new(file_cache: Option<Arc<RwLock<FileStateCache>>>) -> Self {
|
||||
Self {
|
||||
file_cache,
|
||||
write_root: None,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict writes to within `root` (design §3.6 write-root containment).
|
||||
pub fn with_write_root(mut self, root: Option<std::path::PathBuf>) -> Self {
|
||||
self.write_root = root;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve relative `file_path` inputs against `cwd` (the session working
|
||||
/// directory), matching ReadTool/Grep/Glob/Bash. Without this, a relative
|
||||
/// path written by the model lands against the process cwd rather than the
|
||||
/// conversation's workspace.
|
||||
pub fn with_cwd(mut self, cwd: Option<std::path::PathBuf>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"Write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Writes content to a file, creating parent directories if needed.\n\n\
|
||||
Usage:\n\
|
||||
- This tool overwrites the existing file completely (not append).\n\
|
||||
- If the file already exists, you must use Read first to see its current content.\n\
|
||||
- Prefer Edit over Write for modifying existing files — Edit only sends the diff.\n\
|
||||
- Use Write only for creating new files or complete rewrites."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to write (absolute preferred; a relative path resolves against the session working directory)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["file_path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let Some(file_path) = input["file_path"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: file_path".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
let Some(content) = input["content"].as_str() else {
|
||||
return ToolResult {
|
||||
content: "Missing required parameter: content".to_string(),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
// Resolve a relative file_path against the session working directory
|
||||
// (matching ReadTool/Grep/Glob/Bash) before any filesystem use — so a
|
||||
// relative write lands in the conversation workspace, not the process cwd.
|
||||
let resolved = crate::path_guard::resolve_against_cwd(file_path, self.cwd.as_deref());
|
||||
let file_path = resolved.as_str();
|
||||
|
||||
let path = Path::new(file_path);
|
||||
let existed = path.exists();
|
||||
|
||||
// Write-root containment (opt-in): reject writes outside the configured
|
||||
// root before touching the filesystem.
|
||||
if let Some(msg) = crate::path_guard::ensure_within_root(file_path, self.write_root.as_deref()) {
|
||||
return ToolResult {
|
||||
content: msg,
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Enforce "must Read first" for files that already exist: overwriting a
|
||||
// file the model never read silently clobbers content it cannot see.
|
||||
// New files are exempt (Write's purpose is creation). Only enforced when
|
||||
// a file cache is wired; None disables it, preserving legacy behavior.
|
||||
if existed
|
||||
&& let Some(cache_arc) = &self.file_cache
|
||||
&& let Ok(mut cache) = cache_arc.write()
|
||||
{
|
||||
if cache.get(path).is_none() {
|
||||
return ToolResult {
|
||||
content: format!(
|
||||
"You must Read {} before overwriting it — it already exists. \
|
||||
Use the Read tool first, or use Edit for a targeted change.",
|
||||
file_path
|
||||
),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = path.parent().filter(|p| !p.exists()) {
|
||||
match std::fs::create_dir_all(parent) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
content: format!("Failed to create directories: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = format!("{}.tmp.{}", file_path, std::process::id());
|
||||
if let Err(e) = std::fs::write(&tmp_path, content) {
|
||||
return ToolResult {
|
||||
content: format!("Failed to write file: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::rename(&tmp_path, file_path) {
|
||||
// Fallback: direct write if rename fails (cross-device)
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
if let Err(e) = std::fs::write(file_path, content) {
|
||||
return ToolResult {
|
||||
content: format!("Failed to write file: {}", e),
|
||||
is_error: true,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
if let Some(cache_arc) = &self.file_cache {
|
||||
update_cache_after_write(cache_arc, path, content);
|
||||
}
|
||||
|
||||
return ToolResult {
|
||||
content: format!(
|
||||
"Updated {} (rename failed: {}, used direct write)",
|
||||
file_path, e
|
||||
),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(cache_arc) = &self.file_cache {
|
||||
update_cache_after_write(cache_arc, path, content);
|
||||
}
|
||||
|
||||
let line_count = content.lines().count();
|
||||
let action = if existed { "Updated" } else { "Created" };
|
||||
ToolResult {
|
||||
content: format!("{} {} ({} lines)", action, file_path, line_count),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn max_result_size(&self) -> usize {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Edit
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let path = input
|
||||
.get("file_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
format!("Write to {}", path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::Tool;
|
||||
use crate::file_cache::file_mtime_ms;
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
// -- Legacy tests (no cache) --
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_new_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("hello.txt");
|
||||
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "hello world"
|
||||
});
|
||||
|
||||
let tool = WriteTool::new(None);
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"expected success, got: {}",
|
||||
result.content
|
||||
);
|
||||
assert!(file_path.exists(), "file should exist after write");
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("subdir/nested/file.txt");
|
||||
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "nested content"
|
||||
});
|
||||
|
||||
let tool = WriteTool::new(None);
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"expected success, got: {}",
|
||||
result.content
|
||||
);
|
||||
assert!(
|
||||
file_path.parent().unwrap().exists(),
|
||||
"parent dirs should be created"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file_path).unwrap(),
|
||||
"nested content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_overwrite_existing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("overwrite.txt");
|
||||
|
||||
let tool = WriteTool::new(None);
|
||||
|
||||
let input1 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "original"
|
||||
});
|
||||
let result1 = tool.execute(input1).await;
|
||||
assert!(!result1.is_error);
|
||||
assert!(result1.content.contains("Created"));
|
||||
|
||||
let input2 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "replaced"
|
||||
});
|
||||
let result2 = tool.execute(input2).await;
|
||||
assert!(!result2.is_error);
|
||||
assert!(result2.content.contains("Updated"));
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_file_content_matches() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("exact.txt");
|
||||
|
||||
let content = "line 1\nline 2\nline 3\n";
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": content
|
||||
});
|
||||
|
||||
let tool = WriteTool::new(None);
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"expected success, got: {}",
|
||||
result.content
|
||||
);
|
||||
|
||||
let read_back = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(
|
||||
read_back, content,
|
||||
"read-back content must exactly match written content"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Cache integration tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_populates_cache() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("cached.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = WriteTool::new(Some(cache.clone()));
|
||||
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "cached content"
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(!result.is_error, "write failed: {}", result.content);
|
||||
|
||||
// Cache should have an entry with correct mtime.
|
||||
let disk_mtime = file_mtime_ms(&file_path).unwrap();
|
||||
let mut c = cache.write().unwrap();
|
||||
let cached = c
|
||||
.get(&file_path)
|
||||
.expect("file should be in cache after write");
|
||||
assert_eq!(cached.mtime_ms, disk_mtime);
|
||||
assert!(cached.content.contains("cached content"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_edit_succeeds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("write_edit.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let write_tool = WriteTool::new(Some(cache.clone()));
|
||||
let edit_tool = crate::edit::EditTool::new(Some(cache));
|
||||
|
||||
// Write creates the file and populates cache.
|
||||
let write_input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "hello world"
|
||||
});
|
||||
let wr = write_tool.execute(write_input).await;
|
||||
assert!(!wr.is_error, "write failed: {}", wr.content);
|
||||
|
||||
// Edit should succeed without needing a separate Read.
|
||||
let edit_input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
});
|
||||
let er = edit_tool.execute(edit_input).await;
|
||||
assert!(!er.is_error, "edit after write failed: {}", er.content);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file_path).unwrap(),
|
||||
"goodbye world"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_overwriting_unread_existing_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("exists.txt");
|
||||
std::fs::write(&file_path, "original").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = WriteTool::new(Some(cache));
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "clobber"
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(
|
||||
result.is_error,
|
||||
"overwriting an existing file that was never read must be rejected"
|
||||
);
|
||||
assert!(result.content.contains("Read"));
|
||||
// The file must be left untouched.
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "original");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_allows_overwriting_after_read() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("exists2.txt");
|
||||
std::fs::write(&file_path, "original").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
// Simulate a prior Read by populating the cache.
|
||||
update_cache_after_write(&cache, &file_path, "original");
|
||||
let tool = WriteTool::new(Some(cache));
|
||||
let input = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "replaced"
|
||||
});
|
||||
let result = tool.execute(input).await;
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"overwrite after read should succeed: {}",
|
||||
result.content
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_overwrite_updates_cache_mtime() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("overwrite_cache.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = WriteTool::new(Some(cache.clone()));
|
||||
|
||||
// First write.
|
||||
let input1 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "v1"
|
||||
});
|
||||
tool.execute(input1).await;
|
||||
|
||||
let mtime1 = {
|
||||
let mut c = cache.write().unwrap();
|
||||
c.get(&file_path).unwrap().mtime_ms
|
||||
};
|
||||
|
||||
// Brief delay to ensure mtime changes.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Second write.
|
||||
let input2 = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"content": "v2"
|
||||
});
|
||||
tool.execute(input2).await;
|
||||
|
||||
let mtime2 = {
|
||||
let mut c = cache.write().unwrap();
|
||||
c.get(&file_path).unwrap().mtime_ms
|
||||
};
|
||||
|
||||
assert!(
|
||||
mtime2 >= mtime1,
|
||||
"cache mtime should update after overwrite"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_resolves_relative_path_against_cwd() {
|
||||
// The conversation workspace.
|
||||
let workspace = tempdir().unwrap();
|
||||
let tool = WriteTool::new(None).with_cwd(Some(workspace.path().to_path_buf()));
|
||||
|
||||
// A bare relative file name must land INSIDE the workspace, not against
|
||||
// the process cwd (the bug: companion chat reported success but the file
|
||||
// was written to the Tauri launch dir).
|
||||
let rel = "__nomi_reltest_write__.txt";
|
||||
let result = tool
|
||||
.execute(json!({ "file_path": rel, "content": "hello" }))
|
||||
.await;
|
||||
assert!(!result.is_error, "relative write should succeed: {}", result.content);
|
||||
|
||||
let expected = workspace.path().join(rel);
|
||||
assert!(
|
||||
expected.exists(),
|
||||
"relative file_path must resolve against the injected cwd (workspace)"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&expected).unwrap(), "hello");
|
||||
// The success message must echo the resolved (workspace) path.
|
||||
assert!(
|
||||
result.content.contains(&workspace.path().to_string_lossy().into_owned()),
|
||||
"success message should report the resolved path, got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_absolute_path_unaffected_by_cwd() {
|
||||
// With a cwd set, an ABSOLUTE path must be used verbatim (never joined).
|
||||
let workspace = tempdir().unwrap();
|
||||
let target = tempdir().unwrap();
|
||||
let abs = target.path().join("abs.txt");
|
||||
let tool = WriteTool::new(None).with_cwd(Some(workspace.path().to_path_buf()));
|
||||
|
||||
let result = tool
|
||||
.execute(json!({ "file_path": abs.to_str().unwrap(), "content": "x" }))
|
||||
.await;
|
||||
assert!(!result.is_error, "absolute write should succeed: {}", result.content);
|
||||
assert!(abs.exists(), "absolute path must be written as-is");
|
||||
assert!(
|
||||
!workspace.path().join("abs.txt").exists(),
|
||||
"absolute path must NOT be joined onto the cwd"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_root_rejects_outside_and_allows_inside() {
|
||||
let root = tempdir().unwrap();
|
||||
let outside = tempdir().unwrap();
|
||||
let tool = WriteTool::new(None).with_write_root(Some(root.path().to_path_buf()));
|
||||
|
||||
// Outside the root → rejected, nothing written.
|
||||
let escape = outside.path().join("escape.txt");
|
||||
let denied = tool
|
||||
.execute(json!({ "file_path": escape.to_str().unwrap(), "content": "x" }))
|
||||
.await;
|
||||
assert!(denied.is_error, "write outside root must be rejected");
|
||||
assert!(!escape.exists(), "rejected write must not touch disk");
|
||||
|
||||
// Inside the root → allowed.
|
||||
let inside = root.path().join("ok.txt");
|
||||
let ok = tool
|
||||
.execute(json!({ "file_path": inside.to_str().unwrap(), "content": "y" }))
|
||||
.await;
|
||||
assert!(!ok.is_error, "write inside root must succeed: {}", ok.content);
|
||||
assert_eq!(std::fs::read_to_string(&inside).unwrap(), "y");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! `write_stdin`: write characters to an existing `exec_command` session and
|
||||
//! return recent output. With `chars=""` it polls without writing. Ctrl-C is
|
||||
//! `chars=""`.
|
||||
//!
|
||||
//! Shares the same `Arc<ProcessStore>` as `ExecCommandTool`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_types::tool::{JsonSchema, ToolResult};
|
||||
|
||||
use crate::Tool;
|
||||
use crate::output_truncation::{TruncationBudget, truncate_middle};
|
||||
use crate::process_store::{ProcessStore, collect_until_deadline};
|
||||
|
||||
/// Ctrl-C (ETX). On a PTY, writing this byte triggers SIGINT in the foreground.
|
||||
const INTERRUPT: &str = "\u{3}";
|
||||
const MIN_YIELD_MS: u64 = 250;
|
||||
const MAX_YIELD_MS: u64 = 30_000;
|
||||
const MIN_EMPTY_YIELD_MS: u64 = 5_000;
|
||||
const MAX_EMPTY_YIELD_MS: u64 = 300_000;
|
||||
/// Window after a non-empty write before we start polling, so the process has a
|
||||
/// moment to react (mirrors codex's post-write sleep).
|
||||
const POST_WRITE_REACT_MS: u64 = 100;
|
||||
const OUTPUT_CAP_BYTES: usize = 128 * 1024;
|
||||
|
||||
pub struct WriteStdinTool {
|
||||
store: Arc<ProcessStore>,
|
||||
}
|
||||
|
||||
impl WriteStdinTool {
|
||||
pub fn new(store: Arc<ProcessStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteStdinTool {
|
||||
fn name(&self) -> &str {
|
||||
"write_stdin"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Writes characters to an existing exec_command session and returns recent output.\n\n\
|
||||
- chars defaults to empty, which POLLS for output without writing anything.\n\
|
||||
- Send Ctrl-C with chars=\"\\u0003\".\n\
|
||||
- To submit a command line to an interactive program, send the line of text in one \
|
||||
call, then the Enter/return key (\"\\r\") as a SEPARATE write_stdin call — sending text \
|
||||
and the carriage return together can be swallowed by a TUI's paste-burst detection.\n\n\
|
||||
If the process has exited, the result reports its exit_code; otherwise it echoes the \
|
||||
session_id so you can keep interacting."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> JsonSchema {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "number",
|
||||
"description": "Identifier of the running exec_command session."
|
||||
},
|
||||
"chars": {
|
||||
"type": "string",
|
||||
"description": "Bytes to write to stdin. Empty (default) polls for output without writing."
|
||||
},
|
||||
"yield_time_ms": {
|
||||
"type": "number",
|
||||
"description": "Milliseconds to wait for output. With a write: default 250 (max 30000). Empty poll: default 5000 (max 300000)."
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Exec
|
||||
}
|
||||
|
||||
fn describe(&self, input: &Value) -> String {
|
||||
let id = input.get("session_id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let chars = input.get("chars").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if chars.is_empty() {
|
||||
format!("write_stdin: poll session_id={id}")
|
||||
} else {
|
||||
format!("write_stdin: session_id={id} <- {}", crate::truncate_utf8(chars, 40))
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value) -> ToolResult {
|
||||
let id = match input.get("session_id").and_then(|v| v.as_u64()) {
|
||||
Some(i) => i,
|
||||
None => return ToolResult::error("write_stdin: missing required parameter `session_id`"),
|
||||
};
|
||||
let chars = input.get("chars").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let pty = match self.store.touch(id).await {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return ToolResult::error(format!(
|
||||
"write_stdin: unknown or finished session_id={id}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe BEFORE writing so we capture the echo of what we send.
|
||||
let rx = pty.subscribe();
|
||||
if !chars.is_empty() {
|
||||
if let Err(e) = pty.write(chars.as_bytes()) {
|
||||
return ToolResult::error(format!("write_stdin: write failed: {e}"));
|
||||
}
|
||||
// Ctrl-C just needs the signal to land; other input gets a brief
|
||||
// reaction window before we start polling.
|
||||
if chars != INTERRUPT {
|
||||
tokio::time::sleep(Duration::from_millis(POST_WRITE_REACT_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let yield_ms = {
|
||||
let t = input.get("yield_time_ms").and_then(|v| v.as_u64());
|
||||
if chars.is_empty() {
|
||||
t.unwrap_or(MIN_EMPTY_YIELD_MS)
|
||||
.clamp(MIN_EMPTY_YIELD_MS, MAX_EMPTY_YIELD_MS)
|
||||
} else {
|
||||
t.unwrap_or(MIN_YIELD_MS).clamp(MIN_YIELD_MS, MAX_YIELD_MS)
|
||||
}
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(yield_ms);
|
||||
let collected = collect_until_deadline(&pty, rx, deadline).await;
|
||||
let text = truncate_middle(
|
||||
&String::from_utf8_lossy(&collected),
|
||||
TruncationBudget::Bytes(OUTPUT_CAP_BYTES),
|
||||
);
|
||||
|
||||
if pty.has_exited() {
|
||||
let code = pty.exit_code().unwrap_or(-1);
|
||||
// The session is done — drop it from the store.
|
||||
self.store.remove(id).await;
|
||||
ToolResult::text(format!("(process exited, exit_code={code})\n{text}"))
|
||||
} else {
|
||||
ToolResult::text(format!("session_id={id}\n{text}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec_command::ExecCommandTool;
|
||||
use crate::test_support::pty_test_helper_shell_cmd;
|
||||
|
||||
fn parse_session_id(content: &str) -> Option<u64> {
|
||||
content
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("session_id="))
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_session_is_error() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let tool = WriteStdinTool::new(store);
|
||||
let r = tool.execute(serde_json::json!({"session_id": 4242})).await;
|
||||
assert!(r.is_error, "unknown session must error: {}", r.content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_session_id_is_error() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let tool = WriteStdinTool::new(store);
|
||||
let r = tool.execute(serde_json::json!({"chars": "x"})).await;
|
||||
assert!(r.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cat_echoes_written_line() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let exec = ExecCommandTool::new(store.clone(), std::env::current_dir().unwrap());
|
||||
// The helper's `echo-stdin` echoes each written line (cross-platform `cat`).
|
||||
let r = exec
|
||||
.execute(serde_json::json!({
|
||||
"cmd": pty_test_helper_shell_cmd("echo-stdin"),
|
||||
"yield_time_ms": 400
|
||||
}))
|
||||
.await;
|
||||
let sid = parse_session_id(&r.content).expect("echo-stdin should return a session_id");
|
||||
|
||||
let writer = WriteStdinTool::new(store.clone());
|
||||
let r2 = writer
|
||||
.execute(serde_json::json!({"session_id": sid, "chars": "hello_world\n", "yield_time_ms": 1500}))
|
||||
.await;
|
||||
assert!(!r2.is_error, "unexpected error: {}", r2.content);
|
||||
assert!(
|
||||
r2.content.contains("hello_world"),
|
||||
"echo-stdin should echo the written line, got: {}",
|
||||
r2.content
|
||||
);
|
||||
|
||||
// Ctrl-C ends the helper; subsequent polling should observe the exit.
|
||||
let _ = writer
|
||||
.execute(serde_json::json!({"session_id": sid, "chars": "\u{3}", "yield_time_ms": 800}))
|
||||
.await;
|
||||
store.terminate_all().await;
|
||||
}
|
||||
|
||||
// `sh -i` REPL semantics are unix-specific (there is no portable interactive
|
||||
// shell reachable through the `<shell> <flag>` wrapper). The cross-platform
|
||||
// write→echo round-trip through the full tool stack is covered by
|
||||
// `cat_echoes_written_line` above; this keeps the genuine REPL-eval check on
|
||||
// the platform that has one.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn bash_repl_evaluates_expression() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let exec = ExecCommandTool::new(store.clone(), std::env::current_dir().unwrap());
|
||||
let r = exec
|
||||
.execute(serde_json::json!({"cmd": "sh -i", "yield_time_ms": 500}))
|
||||
.await;
|
||||
let sid = match parse_session_id(&r.content) {
|
||||
Some(s) => s,
|
||||
// Some CI shells exit `sh -i` without a controlling tty quirk; skip
|
||||
// rather than flake (environmental, per test-workflow-rules-macos).
|
||||
None => return,
|
||||
};
|
||||
let writer = WriteStdinTool::new(store.clone());
|
||||
// Send the expression and the newline together here — a plain REPL (not a
|
||||
// TUI) accepts the burst; the split-Enter guidance is for TUIs.
|
||||
let r2 = writer
|
||||
.execute(serde_json::json!({"session_id": sid, "chars": "echo $((6*7))\n", "yield_time_ms": 1500}))
|
||||
.await;
|
||||
assert!(
|
||||
r2.content.contains("42"),
|
||||
"sh REPL should evaluate 6*7=42, got: {}",
|
||||
r2.content
|
||||
);
|
||||
store.terminate_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_poll_picks_up_delayed_output() {
|
||||
let store = Arc::new(ProcessStore::new());
|
||||
let exec = ExecCommandTool::new(store.clone(), std::env::current_dir().unwrap());
|
||||
// Emits "late_line" after ~300ms, then keeps the session alive ~5s. The
|
||||
// helper's `emit-after` is deterministic and cross-platform (replaces the
|
||||
// shell-specific `sleep 0.3; echo ...; sleep 5`).
|
||||
let r = exec
|
||||
.execute(serde_json::json!({
|
||||
"cmd": pty_test_helper_shell_cmd("emit-after 300 late_line 5000"),
|
||||
"yield_time_ms": 100
|
||||
}))
|
||||
.await;
|
||||
let sid = parse_session_id(&r.content)
|
||||
.expect("process should still be running at 100ms (it sleeps first)");
|
||||
assert!(
|
||||
!r.content.contains("late_line"),
|
||||
"late_line should NOT appear in the first 100ms window: {}",
|
||||
r.content
|
||||
);
|
||||
|
||||
let writer = WriteStdinTool::new(store.clone());
|
||||
// Empty poll (no write) with a generous window must catch the late line.
|
||||
let r2 = writer
|
||||
.execute(serde_json::json!({"session_id": sid, "chars": "", "yield_time_ms": 5000}))
|
||||
.await;
|
||||
assert!(
|
||||
r2.content.contains("late_line"),
|
||||
"empty poll should pick up delayed output, got: {}",
|
||||
r2.content
|
||||
);
|
||||
store.terminate_all().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Integration tests for EditTool / WriteTool file-state cache integration
|
||||
//! (TC-5.4 and TC-5.4-W series).
|
||||
//!
|
||||
//! Black-box tests: exercise Edit/Write tools through their public API with
|
||||
//! a real filesystem and shared FileStateCache, validating "must Read first"
|
||||
//! guard, staleness detection, and post-write cache updates.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::edit::EditTool;
|
||||
use nomi_tools::file_cache::{FileStateCache, file_mtime_ms};
|
||||
use nomi_tools::read::ReadTool;
|
||||
use nomi_tools::write::WriteTool;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
/// Populate cache by actually reading the file through ReadTool.
|
||||
async fn read_file(tool: &ReadTool, path: &Path) {
|
||||
let input = json!({ "file_path": path.to_str().unwrap() });
|
||||
let r = tool.execute(input).await;
|
||||
assert!(!r.is_error, "read failed: {}", r.content);
|
||||
}
|
||||
|
||||
const UNCHANGED_MARKER: &str = "File unchanged since last read";
|
||||
|
||||
// ==========================================================================
|
||||
// TC-5.4: EditTool guard and staleness detection
|
||||
// ==========================================================================
|
||||
|
||||
/// TC-5.4-01: Normal Read → Edit succeeds.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_01_read_then_edit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("normal.txt");
|
||||
std::fs::write(&file, "hello world").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
read_file(&read_tool, &file).await;
|
||||
|
||||
let input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
});
|
||||
let result = edit_tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"Edit after Read should succeed: {}",
|
||||
result.content
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "goodbye world");
|
||||
}
|
||||
|
||||
/// TC-5.4-02: Edit without prior Read returns "must Read first" error.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_02_edit_without_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("no_read.txt");
|
||||
std::fs::write(&file, "content").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
let input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "content",
|
||||
"new_string": "new"
|
||||
});
|
||||
let result = edit_tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error, "Edit without Read should fail");
|
||||
assert!(
|
||||
result.content.contains("must Read"),
|
||||
"Error should mention 'must Read': {}",
|
||||
result.content
|
||||
);
|
||||
// File must be unchanged.
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "content");
|
||||
}
|
||||
|
||||
/// TC-5.4-03: External modification after Read triggers staleness error.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_03_external_modification_detected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("stale.txt");
|
||||
std::fs::write(&file, "original content").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
read_file(&read_tool, &file).await;
|
||||
|
||||
// External modification.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file, "externally changed").unwrap();
|
||||
|
||||
let input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "original content",
|
||||
"new_string": "new"
|
||||
});
|
||||
let result = edit_tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
result.is_error,
|
||||
"Edit of externally modified file should fail"
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("modified externally"),
|
||||
"Error should mention external modification: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.4-04: Edit → Edit succeeds because first Edit updates the cache.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_04_edit_then_edit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("double.txt");
|
||||
std::fs::write(&file, "aaa bbb ccc").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
read_file(&read_tool, &file).await;
|
||||
|
||||
// First edit.
|
||||
let input1 = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "aaa",
|
||||
"new_string": "AAA"
|
||||
});
|
||||
let r1 = edit_tool.execute(input1).await;
|
||||
assert!(!r1.is_error, "First edit failed: {}", r1.content);
|
||||
|
||||
// Second edit — should work because first edit updated cache mtime.
|
||||
let input2 = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "bbb",
|
||||
"new_string": "BBB"
|
||||
});
|
||||
let r2 = edit_tool.execute(input2).await;
|
||||
assert!(!r2.is_error, "Second edit failed: {}", r2.content);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "AAA BBB ccc");
|
||||
}
|
||||
|
||||
/// TC-5.4-05: With cache disabled (None), Edit works without prior Read.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_05_no_cache_edit_bypasses_guard() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("nocache.txt");
|
||||
std::fs::write(&file, "hello").unwrap();
|
||||
|
||||
let edit_tool = EditTool::new(None);
|
||||
|
||||
let input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "bye"
|
||||
});
|
||||
let result = edit_tool.execute(input).await;
|
||||
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"Edit without cache should succeed: {}",
|
||||
result.content
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "bye");
|
||||
}
|
||||
|
||||
/// TC-5.4-06: replace_all updates cache mtime correctly.
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_06_replace_all_updates_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("replaceall.txt");
|
||||
std::fs::write(&file, "x-x-x-x").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
let edit_tool = EditTool::new(Some(cache.clone()));
|
||||
|
||||
read_file(&read_tool, &file).await;
|
||||
|
||||
let input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
"replace_all": true
|
||||
});
|
||||
let result = edit_tool.execute(input).await;
|
||||
assert!(!result.is_error, "replace_all failed: {}", result.content);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "y-y-y-y");
|
||||
|
||||
// Verify cache mtime matches disk.
|
||||
let disk_mtime = file_mtime_ms(&file).unwrap();
|
||||
let mut c = cache.write().unwrap();
|
||||
let cached = c.get(&file).expect("file should be in cache");
|
||||
assert_eq!(cached.mtime_ms, disk_mtime);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TC-5.4-W: WriteTool cache update
|
||||
// ==========================================================================
|
||||
|
||||
/// TC-5.4-W01: Write then Read returns "unchanged" (Write populates cache).
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_w01_write_then_read_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("write_read.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let write_tool = WriteTool::new(Some(cache.clone()));
|
||||
let read_tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
// Write creates file and populates cache.
|
||||
let write_input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "written content"
|
||||
});
|
||||
let wr = write_tool.execute(write_input).await;
|
||||
assert!(!wr.is_error, "write failed: {}", wr.content);
|
||||
|
||||
// Read immediately after: should return "unchanged" because Write
|
||||
// already cached the content with the correct mtime.
|
||||
let read_input = json!({ "file_path": file.to_str().unwrap() });
|
||||
let rr = read_tool.execute(read_input).await;
|
||||
assert!(!rr.is_error);
|
||||
assert!(
|
||||
rr.content.contains(UNCHANGED_MARKER),
|
||||
"Read after Write should return unchanged stub, got: {}",
|
||||
rr.content
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.4-W02: Write then Edit succeeds (Write populates cache for Edit guard).
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_w02_write_then_edit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("write_edit.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let write_tool = WriteTool::new(Some(cache.clone()));
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
let write_input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "hello world"
|
||||
});
|
||||
let wr = write_tool.execute(write_input).await;
|
||||
assert!(!wr.is_error, "write failed: {}", wr.content);
|
||||
|
||||
let edit_input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
});
|
||||
let er = edit_tool.execute(edit_input).await;
|
||||
assert!(
|
||||
!er.is_error,
|
||||
"Edit after Write should succeed: {}",
|
||||
er.content
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "goodbye world");
|
||||
}
|
||||
|
||||
/// TC-5.4-W03: Write → Write → Read returns fresh content (mtime updated).
|
||||
#[tokio::test]
|
||||
async fn tc_5_4_w03_write_overwrite_then_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("overwrite.txt");
|
||||
|
||||
let cache = make_cache();
|
||||
let write_tool = WriteTool::new(Some(cache.clone()));
|
||||
let read_tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
// First write.
|
||||
let w1 = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "version 1"
|
||||
});
|
||||
write_tool.execute(w1).await;
|
||||
|
||||
// Brief delay to change mtime.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Second write (overwrite).
|
||||
let w2 = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "version 2"
|
||||
});
|
||||
write_tool.execute(w2).await;
|
||||
|
||||
// Read: cache was updated by second Write, so should see "unchanged"
|
||||
// (cache content matches disk content with matching mtime).
|
||||
let read_input = json!({ "file_path": file.to_str().unwrap() });
|
||||
let rr = read_tool.execute(read_input).await;
|
||||
assert!(!rr.is_error);
|
||||
// The cache was updated by the second Write with the new content,
|
||||
// so Read should hit the dedup path.
|
||||
assert!(
|
||||
rr.content.contains(UNCHANGED_MARKER),
|
||||
"Read after second Write should dedup, got: {}",
|
||||
rr.content
|
||||
);
|
||||
|
||||
// Verify disk has version 2.
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "version 2");
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Supplementary: Cross-tool interaction tests
|
||||
// ==========================================================================
|
||||
|
||||
/// Read → Edit → Read should dedup (Edit updated the cache).
|
||||
#[tokio::test]
|
||||
async fn read_edit_read_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("cross.txt");
|
||||
std::fs::write(&file, "alpha beta").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
let edit_tool = EditTool::new(Some(cache));
|
||||
|
||||
// Read.
|
||||
read_file(&read_tool, &file).await;
|
||||
|
||||
// Edit.
|
||||
let edit_input = json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "alpha",
|
||||
"new_string": "ALPHA"
|
||||
});
|
||||
let er = edit_tool.execute(edit_input).await;
|
||||
assert!(!er.is_error);
|
||||
|
||||
// Read again: Edit updated the cache, so Read should see "unchanged".
|
||||
let read_input = json!({ "file_path": file.to_str().unwrap() });
|
||||
let rr = read_tool.execute(read_input).await;
|
||||
assert!(!rr.is_error);
|
||||
assert!(
|
||||
rr.content.contains(UNCHANGED_MARKER),
|
||||
"Read after Edit should dedup, got: {}",
|
||||
rr.content
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Integration tests for FileStateCache (TC-5.2 series from test-plan.md).
|
||||
//!
|
||||
//! Black-box tests targeting the public API of FileStateCache without
|
||||
//! depending on internal implementation details.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_tools::file_cache::{FileStateCache, file_mtime_ms, update_cache_after_write};
|
||||
use nomi_types::file_state::FileState;
|
||||
|
||||
fn default_config() -> FileCacheConfig {
|
||||
FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state(content: &str, mtime_ms: u64) -> FileState {
|
||||
FileState {
|
||||
content: content.to_string(),
|
||||
mtime_ms,
|
||||
offset: None,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TC-5.2-01: Insert and retrieve a file state entry.
|
||||
#[test]
|
||||
fn tc_5_2_01_insert_and_retrieve() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
let path = PathBuf::from("/home/user/project/main.rs");
|
||||
let state = make_state(" 1\tfn main() {}", 1_700_000_000_000);
|
||||
|
||||
cache.insert(path.clone(), state);
|
||||
|
||||
let retrieved = cache.get(&path).expect("entry should exist");
|
||||
assert_eq!(retrieved.content, " 1\tfn main() {}");
|
||||
assert_eq!(retrieved.mtime_ms, 1_700_000_000_000);
|
||||
assert!(retrieved.offset.is_none());
|
||||
assert!(retrieved.limit.is_none());
|
||||
}
|
||||
|
||||
/// TC-5.2-02: Getting a non-existent key returns None.
|
||||
#[test]
|
||||
fn tc_5_2_02_nonexistent_key() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
assert!(cache.get(Path::new("/no/such/file.rs")).is_none());
|
||||
}
|
||||
|
||||
/// TC-5.2-03: LRU eviction when count exceeds max_entries.
|
||||
#[test]
|
||||
fn tc_5_2_03_lru_count_eviction() {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 3,
|
||||
max_size_bytes: 10_000_000,
|
||||
enabled: true,
|
||||
};
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/f1"), make_state("1", 1));
|
||||
cache.insert(PathBuf::from("/f2"), make_state("2", 2));
|
||||
cache.insert(PathBuf::from("/f3"), make_state("3", 3));
|
||||
|
||||
// 4th insert should evict /f1 (the LRU)
|
||||
cache.insert(PathBuf::from("/f4"), make_state("4", 4));
|
||||
|
||||
assert!(
|
||||
cache.get(Path::new("/f1")).is_none(),
|
||||
"/f1 should be evicted"
|
||||
);
|
||||
assert!(cache.get(Path::new("/f2")).is_some());
|
||||
assert!(cache.get(Path::new("/f3")).is_some());
|
||||
assert!(cache.get(Path::new("/f4")).is_some());
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
/// TC-5.2-04: Path normalization ensures equivalent paths hit the same slot.
|
||||
#[test]
|
||||
fn tc_5_2_04_path_normalization() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
// Insert with redundant `..` in path
|
||||
cache.insert(
|
||||
PathBuf::from("/project/src/../lib/file.rs"),
|
||||
make_state("content", 100),
|
||||
);
|
||||
|
||||
// Retrieve using canonical-style path
|
||||
let got = cache
|
||||
.get(Path::new("/project/lib/file.rs"))
|
||||
.expect("normalized path should hit cache");
|
||||
assert_eq!(got.content, "content");
|
||||
|
||||
// Only one entry in cache
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// TC-5.2-05: clear() removes all entries and resets size accounting.
|
||||
#[test]
|
||||
fn tc_5_2_05_clear() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
cache.insert(PathBuf::from("/a"), make_state("aaa", 1));
|
||||
cache.insert(PathBuf::from("/b"), make_state("bbb", 2));
|
||||
cache.insert(PathBuf::from("/c"), make_state("ccc", 3));
|
||||
assert_eq!(cache.len(), 3);
|
||||
assert!(cache.current_size_bytes() > 0);
|
||||
|
||||
cache.clear();
|
||||
assert_eq!(cache.len(), 0);
|
||||
assert!(cache.is_empty());
|
||||
assert_eq!(cache.current_size_bytes(), 0);
|
||||
assert!(cache.get(Path::new("/a")).is_none());
|
||||
assert!(cache.get(Path::new("/b")).is_none());
|
||||
assert!(cache.get(Path::new("/c")).is_none());
|
||||
}
|
||||
|
||||
/// TC-5.2-06: remove() deletes a specific entry and returns it.
|
||||
#[test]
|
||||
fn tc_5_2_06_remove() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
cache.insert(PathBuf::from("/target"), make_state("data", 1));
|
||||
cache.insert(PathBuf::from("/keep"), make_state("keep", 2));
|
||||
|
||||
let removed = cache.remove(Path::new("/target"));
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(removed.unwrap().content, "data");
|
||||
|
||||
assert!(cache.get(Path::new("/target")).is_none());
|
||||
assert!(cache.get(Path::new("/keep")).is_some());
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// TC-5.2-07: Byte-size limit triggers LRU eviction of old entries.
|
||||
#[test]
|
||||
fn tc_5_2_07_byte_size_eviction() {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 15, // tight byte budget
|
||||
enabled: true,
|
||||
};
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
// Insert two 6-byte entries: total = 12, within budget
|
||||
cache.insert(PathBuf::from("/a"), make_state("aaaaaa", 1)); // 6 bytes
|
||||
cache.insert(PathBuf::from("/b"), make_state("bbbbbb", 2)); // 6 bytes
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert_eq!(cache.current_size_bytes(), 12);
|
||||
|
||||
// Insert 6-byte entry: 12 + 6 = 18 > 15 -> evicts /a (LRU), total = 12
|
||||
cache.insert(PathBuf::from("/c"), make_state("cccccc", 3));
|
||||
assert!(cache.get(Path::new("/a")).is_none(), "/a should be evicted");
|
||||
assert!(cache.get(Path::new("/b")).is_some());
|
||||
assert!(cache.get(Path::new("/c")).is_some());
|
||||
assert!(cache.current_size_bytes() <= 15);
|
||||
}
|
||||
|
||||
/// TC-5.2-08: Inserting the same path twice updates (overwrites) the entry.
|
||||
#[test]
|
||||
fn tc_5_2_08_overwrite_update() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
cache.insert(PathBuf::from("/file"), make_state("version1", 100));
|
||||
cache.insert(PathBuf::from("/file"), make_state("version2-updated", 200));
|
||||
|
||||
let got = cache.get(Path::new("/file")).expect("entry should exist");
|
||||
assert_eq!(got.content, "version2-updated");
|
||||
assert_eq!(got.mtime_ms, 200);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert_eq!(cache.current_size_bytes(), "version2-updated".len());
|
||||
}
|
||||
|
||||
/// Supplementary: LRU promotion via get() prevents eviction of accessed entries.
|
||||
#[test]
|
||||
fn lru_promotion_via_get() {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 3,
|
||||
max_size_bytes: 10_000_000,
|
||||
enabled: true,
|
||||
};
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
cache.insert(PathBuf::from("/oldest"), make_state("o", 1));
|
||||
cache.insert(PathBuf::from("/middle"), make_state("m", 2));
|
||||
cache.insert(PathBuf::from("/newest"), make_state("n", 3));
|
||||
|
||||
// Access /oldest to promote it; /middle becomes the new LRU
|
||||
cache.get(Path::new("/oldest"));
|
||||
|
||||
// Insert /extra -> evicts /middle (now the LRU)
|
||||
cache.insert(PathBuf::from("/extra"), make_state("e", 4));
|
||||
|
||||
assert!(
|
||||
cache.get(Path::new("/oldest")).is_some(),
|
||||
"/oldest was promoted and should survive"
|
||||
);
|
||||
assert!(
|
||||
cache.get(Path::new("/middle")).is_none(),
|
||||
"/middle should be evicted as the new LRU"
|
||||
);
|
||||
}
|
||||
|
||||
/// Supplementary: remove on a non-existent key returns None without panic.
|
||||
#[test]
|
||||
fn remove_nonexistent_returns_none() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
assert!(cache.remove(Path::new("/ghost")).is_none());
|
||||
}
|
||||
|
||||
/// Supplementary: partial read state (offset + limit) is preserved.
|
||||
#[test]
|
||||
fn partial_read_state_round_trip() {
|
||||
let mut cache = FileStateCache::new(&default_config());
|
||||
|
||||
let state = FileState {
|
||||
content: "partial".to_string(),
|
||||
mtime_ms: 999,
|
||||
offset: Some(10),
|
||||
limit: Some(20),
|
||||
};
|
||||
cache.insert(PathBuf::from("/partial"), state);
|
||||
|
||||
let got = cache.get(Path::new("/partial")).unwrap();
|
||||
assert_eq!(got.offset, Some(10));
|
||||
assert_eq!(got.limit, Some(20));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TC-5.5: update_cache_after_write helper and config integration
|
||||
// ==========================================================================
|
||||
|
||||
/// TC-5.5-01: Cache created with custom max_entries has correct capacity.
|
||||
#[test]
|
||||
fn tc_5_5_01_custom_capacity() {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 50,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
let mut cache = FileStateCache::new(&config);
|
||||
|
||||
// Insert 50 entries: all should fit.
|
||||
for i in 0..50 {
|
||||
cache.insert(PathBuf::from(format!("/f{}", i)), make_state("x", i));
|
||||
}
|
||||
assert_eq!(cache.len(), 50);
|
||||
|
||||
// 51st entry evicts the LRU.
|
||||
cache.insert(PathBuf::from("/f50"), make_state("x", 50));
|
||||
assert_eq!(cache.len(), 50);
|
||||
assert!(
|
||||
cache.get(Path::new("/f0")).is_none(),
|
||||
"/f0 should be evicted at capacity 50"
|
||||
);
|
||||
}
|
||||
|
||||
/// update_cache_after_write stores line-numbered content with correct mtime.
|
||||
#[test]
|
||||
fn update_cache_after_write_stores_numbered_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("helper_test.txt");
|
||||
let content = "line one\nline two\nline three";
|
||||
std::fs::write(&file, content).unwrap();
|
||||
|
||||
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
||||
update_cache_after_write(&cache_arc, &file, content);
|
||||
|
||||
let mut cache = cache_arc.write().unwrap();
|
||||
let cached = cache.get(&file).expect("entry should exist after update");
|
||||
|
||||
// Content should be line-numbered.
|
||||
assert!(cached.content.contains(" 1\tline one"));
|
||||
assert!(cached.content.contains(" 2\tline two"));
|
||||
assert!(cached.content.contains(" 3\tline three"));
|
||||
|
||||
// Mtime should match disk.
|
||||
let disk_mtime = file_mtime_ms(&file).unwrap();
|
||||
assert_eq!(cached.mtime_ms, disk_mtime);
|
||||
|
||||
// Offset and limit should be None (full file).
|
||||
assert!(cached.offset.is_none());
|
||||
assert!(cached.limit.is_none());
|
||||
}
|
||||
|
||||
/// update_cache_after_write handles empty content.
|
||||
#[test]
|
||||
fn update_cache_after_write_empty_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("empty.txt");
|
||||
std::fs::write(&file, "").unwrap();
|
||||
|
||||
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
||||
update_cache_after_write(&cache_arc, &file, "");
|
||||
|
||||
let mut cache = cache_arc.write().unwrap();
|
||||
let cached = cache.get(&file).expect("entry should exist");
|
||||
assert_eq!(cached.content, "");
|
||||
}
|
||||
|
||||
/// update_cache_after_write overwrites previous entry.
|
||||
#[test]
|
||||
fn update_cache_after_write_overwrites_previous() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("overwrite.txt");
|
||||
|
||||
std::fs::write(&file, "v1").unwrap();
|
||||
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
||||
update_cache_after_write(&cache_arc, &file, "v1");
|
||||
|
||||
// Brief delay for mtime change.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file, "v2 updated").unwrap();
|
||||
update_cache_after_write(&cache_arc, &file, "v2 updated");
|
||||
|
||||
let mut cache = cache_arc.write().unwrap();
|
||||
let cached = cache.get(&file).unwrap();
|
||||
assert!(cached.content.contains("v2 updated"));
|
||||
assert_eq!(cached.mtime_ms, file_mtime_ms(&file).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Integration tests for ReadTool dedup and cache integration (TC-5.3 series).
|
||||
//!
|
||||
//! Black-box tests: exercise ReadTool through its public API with a real
|
||||
//! filesystem, validating dedup detection and cache update behavior.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::file_cache::FileStateCache;
|
||||
use nomi_tools::read::ReadTool;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
const UNCHANGED_MARKER: &str = "File unchanged since last read";
|
||||
|
||||
/// TC-5.3-01: First read returns full content with line numbers.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_01_first_read_returns_full_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("hello.rs");
|
||||
std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(result.content.contains("1\tfn main()"));
|
||||
assert!(result.content.contains("2\t println!"));
|
||||
assert!(result.content.contains("3\t}"));
|
||||
assert!(
|
||||
!result.content.contains(UNCHANGED_MARKER),
|
||||
"First read must not return the unchanged stub"
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.3-02: Second read of the same unchanged file returns the dedup stub.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_02_dedup_on_unchanged_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("stable.txt");
|
||||
std::fs::write(&file, "line one\nline two\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
|
||||
// First read: full content.
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error);
|
||||
assert!(r1.content.contains("line one"));
|
||||
|
||||
// Second read: unchanged stub.
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(
|
||||
r2.content.contains(UNCHANGED_MARKER),
|
||||
"Second read of unchanged file should return the dedup stub"
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.3-03: After external modification, re-read returns new content.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_03_modified_file_returns_new_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("evolving.txt");
|
||||
std::fs::write(&file, "version 1\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(r1.content.contains("version 1"));
|
||||
|
||||
// External modification — sleep to ensure mtime changes.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file, "version 2\n").unwrap();
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(
|
||||
r2.content.contains("version 2"),
|
||||
"After modification, read should return new content"
|
||||
);
|
||||
assert!(
|
||||
!r2.content.contains(UNCHANGED_MARKER),
|
||||
"Modified file must not return unchanged stub"
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.3-04: Different offset/limit parameters are not deduped.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_04_different_range_no_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("multiline.txt");
|
||||
let content: String = (1..=30).map(|i| format!("line {}\n", i)).collect();
|
||||
std::fs::write(&file, &content).unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let path_str = file.to_str().unwrap();
|
||||
|
||||
// Read lines 0..10.
|
||||
let input1 = json!({ "file_path": path_str, "offset": 0, "limit": 10 });
|
||||
let r1 = tool.execute(input1).await;
|
||||
assert!(!r1.is_error);
|
||||
assert!(r1.content.contains("line 1"));
|
||||
|
||||
// Read lines 10..20 — different range, should return full content.
|
||||
let input2 = json!({ "file_path": path_str, "offset": 10, "limit": 10 });
|
||||
let r2 = tool.execute(input2).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(
|
||||
r2.content.contains("line 11"),
|
||||
"Different offset/limit should return full content"
|
||||
);
|
||||
assert!(
|
||||
!r2.content.contains(UNCHANGED_MARKER),
|
||||
"Different range must not trigger dedup"
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-5.3-05: With cache disabled (None), reads always return full content.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_05_cache_disabled_no_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("nocache.txt");
|
||||
std::fs::write(&file, "always full\n").unwrap();
|
||||
|
||||
let tool = ReadTool::new(None, None);
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(r1.content.contains("always full"));
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(
|
||||
r2.content.contains("always full"),
|
||||
"Without cache, second read should still return full content"
|
||||
);
|
||||
assert!(!r2.content.contains(UNCHANGED_MARKER));
|
||||
}
|
||||
|
||||
/// TC-5.3-06: Reading a non-existent file returns an error and does not cache.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_06_nonexistent_file_error_no_cache() {
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache.clone()), None);
|
||||
|
||||
let input = json!({ "file_path": "/tmp/does_not_exist_tc_5_3_06.txt" });
|
||||
let result = tool.execute(input).await;
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("Failed to read file"));
|
||||
|
||||
// Cache should remain empty.
|
||||
let c = cache.read().unwrap();
|
||||
assert!(c.is_empty(), "Failed reads must not populate the cache");
|
||||
}
|
||||
|
||||
/// TC-5.3-07: Empty file can be deduped on second read.
|
||||
#[tokio::test]
|
||||
async fn tc_5_3_07_empty_file_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("empty.txt");
|
||||
std::fs::File::create(&file).unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error);
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(
|
||||
r2.content.contains(UNCHANGED_MARKER),
|
||||
"Empty file should be deduped on second read"
|
||||
);
|
||||
}
|
||||
|
||||
/// Supplementary: Same range read twice returns dedup stub.
|
||||
#[tokio::test]
|
||||
async fn same_range_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("range.txt");
|
||||
let content: String = (1..=20).map(|i| format!("line {}\n", i)).collect();
|
||||
std::fs::write(&file, &content).unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap(), "offset": 5, "limit": 5 });
|
||||
|
||||
let r1 = tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error);
|
||||
assert!(r1.content.contains("line 6"));
|
||||
|
||||
let r2 = tool.execute(input).await;
|
||||
assert!(!r2.is_error);
|
||||
assert!(
|
||||
r2.content.contains(UNCHANGED_MARKER),
|
||||
"Same range on unchanged file should trigger dedup"
|
||||
);
|
||||
}
|
||||
|
||||
/// Supplementary: Cache entry is updated after modification + re-read.
|
||||
#[tokio::test]
|
||||
async fn cache_updated_after_modification() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("update.txt");
|
||||
std::fs::write(&file, "v1\n").unwrap();
|
||||
|
||||
let cache = make_cache();
|
||||
let tool = ReadTool::new(Some(cache.clone()), None);
|
||||
|
||||
let input = json!({ "file_path": file.to_str().unwrap() });
|
||||
|
||||
// First read: caches v1.
|
||||
tool.execute(input.clone()).await;
|
||||
|
||||
// Modify.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
std::fs::write(&file, "v2\n").unwrap();
|
||||
|
||||
// Second read: returns v2 and updates cache.
|
||||
let r2 = tool.execute(input.clone()).await;
|
||||
assert!(r2.content.contains("v2"));
|
||||
|
||||
// Third read: should dedup on v2.
|
||||
let r3 = tool.execute(input).await;
|
||||
assert!(
|
||||
r3.content.contains(UNCHANGED_MARKER),
|
||||
"After re-read of modified file, cache should be updated and third read deduped"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Integration tests for enhanced tool descriptions (TC-4.2-01 through TC-4.2-08).
|
||||
//!
|
||||
//! These are black-box tests that verify each tool's description contains
|
||||
//! the key guidance information specified in the test plan.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::bash::BashTool;
|
||||
use nomi_tools::edit::EditTool;
|
||||
use nomi_tools::glob::GlobTool;
|
||||
use nomi_tools::grep::GrepTool;
|
||||
use nomi_tools::read::ReadTool;
|
||||
use nomi_tools::registry::ToolRegistry;
|
||||
use nomi_tools::write::WriteTool;
|
||||
|
||||
fn test_cwd() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
// --- TC-4.2-01: Bash tool description contains key guidance ---
|
||||
|
||||
#[test]
|
||||
fn bash_description_references_dedicated_tools() {
|
||||
let tool = BashTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("Glob"),
|
||||
"Bash description should cross-reference Glob tool"
|
||||
);
|
||||
assert!(
|
||||
desc.contains("Grep"),
|
||||
"Bash description should cross-reference Grep tool"
|
||||
);
|
||||
assert!(
|
||||
desc.contains("Read"),
|
||||
"Bash description should cross-reference Read tool"
|
||||
);
|
||||
assert!(
|
||||
desc.contains("Edit"),
|
||||
"Bash description should cross-reference Edit tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_description_contains_timeout_info() {
|
||||
let tool = BashTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("120") || desc.to_lowercase().contains("timeout"),
|
||||
"Bash description should mention timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_description_contains_parallel_guidance() {
|
||||
let tool = BashTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("parallel") || desc.contains("&&"),
|
||||
"Bash description should contain parallel command guidance"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-02: Read tool description contains usage constraints ---
|
||||
|
||||
#[test]
|
||||
fn read_description_requires_absolute_path() {
|
||||
let tool = ReadTool::new(None, None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("absolute path"),
|
||||
"Read description should mention absolute path requirement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_description_mentions_line_numbers() {
|
||||
let tool = ReadTool::new(None, None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("line number"),
|
||||
"Read description should explain line number output format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_description_handles_binary() {
|
||||
let tool = ReadTool::new(None, None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.to_lowercase().contains("binary"),
|
||||
"Read description should mention binary file handling"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-03: Edit tool description contains preconditions ---
|
||||
|
||||
#[test]
|
||||
fn edit_description_requires_read_first() {
|
||||
let tool = EditTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("Read"),
|
||||
"Edit description should require Read before editing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_description_mentions_uniqueness() {
|
||||
let tool = EditTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("unique"),
|
||||
"Edit description should mention old_string uniqueness requirement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_description_mentions_replace_all() {
|
||||
let tool = EditTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("replace_all"),
|
||||
"Edit description should document replace_all option"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-04: Write tool description contains operation semantics ---
|
||||
|
||||
#[test]
|
||||
fn write_description_mentions_overwrite() {
|
||||
let tool = WriteTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("overwrite") || desc.contains("overwrites"),
|
||||
"Write description should explain overwrite semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_description_requires_read_for_existing() {
|
||||
let tool = WriteTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("Read"),
|
||||
"Write description should mention reading existing files first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_description_prefers_edit() {
|
||||
let tool = WriteTool::new(None);
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("Edit"),
|
||||
"Write description should recommend Edit for modifications"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-05: Glob tool description contains result limits ---
|
||||
|
||||
#[test]
|
||||
fn glob_description_mentions_result_limit() {
|
||||
let tool = GlobTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("100"),
|
||||
"Glob description should mention the 100 result limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_description_mentions_sort_order() {
|
||||
let tool = GlobTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
let lower = desc.to_lowercase();
|
||||
assert!(
|
||||
lower.contains("modification time") || lower.contains("newest"),
|
||||
"Glob description should explain sort order"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-06: Grep tool description contains mandatory usage rule ---
|
||||
|
||||
#[test]
|
||||
fn grep_description_forbids_bash_grep() {
|
||||
let tool = GrepTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("NEVER") || desc.contains("never"),
|
||||
"Grep description should forbid using grep in Bash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_description_mentions_regex() {
|
||||
let tool = GrepTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("regex"),
|
||||
"Grep description should mention regex support"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_description_mentions_result_limit() {
|
||||
let tool = GrepTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
desc.contains("250"),
|
||||
"Grep description should mention the 250 result limit"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.3-09: Grep description accuracy fix (R-4.2-01) ---
|
||||
|
||||
#[test]
|
||||
fn grep_description_does_not_say_at_most_matches() {
|
||||
let tool = GrepTool::new(test_cwd());
|
||||
let desc = tool.description();
|
||||
assert!(
|
||||
!desc.contains("at most 250 matches"),
|
||||
"Grep description should not say 'at most 250 matches' (was per-file, not global)"
|
||||
);
|
||||
assert!(
|
||||
desc.contains("capped at 250 lines"),
|
||||
"Grep description should accurately describe the 250-line cap"
|
||||
);
|
||||
}
|
||||
|
||||
// --- TC-4.2-08: ToolDef propagation ---
|
||||
|
||||
#[test]
|
||||
fn tool_def_description_matches_tool_instance() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Box::new(BashTool::new(test_cwd())));
|
||||
registry.register(Box::new(ReadTool::new(None, None)));
|
||||
registry.register(Box::new(EditTool::new(None)));
|
||||
registry.register(Box::new(WriteTool::new(None)));
|
||||
registry.register(Box::new(GlobTool::new(test_cwd())));
|
||||
registry.register(Box::new(GrepTool::new(test_cwd())));
|
||||
|
||||
let defs = registry.to_tool_defs();
|
||||
|
||||
for def in &defs {
|
||||
let tool = registry
|
||||
.get(&def.name)
|
||||
.expect("tool should exist in registry");
|
||||
assert_eq!(
|
||||
def.description,
|
||||
tool.description(),
|
||||
"ToolDef description for '{}' should match Tool::description()",
|
||||
def.name
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user