Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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
);
}
}