repositories / smith
smith
There are many coding harnesses - but this one is fast
owned by admin
smith-core/src/tools.rs
Raw//! Tool registry, validation, built-in file effects, and the durable
//! outcome boundary between successful effects and recorded session frames.
//!
//! (`SMH-SPEC-SPEC0001`, Tools).
use crate::session::{Entry, EntryContentRef, Session};
use crate::store::SessionWriter;
use smith::config::Config;
use smith::error::{Result, SmithError};
use smith::id::ToolCallId;
use smith::tool::{CancelHandle, ToolMetadata};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
const HARD_OUTPUT_CAP: usize = 256 * 1024;
const DEFAULT_OUTPUT_CAP: usize = 64 * 1024;
const MAX_LIST_ENTRIES: usize = 1000;
const MAX_GREP_MATCHES: usize = 200;
const MAX_EDIT_FILE_BYTES: u64 = 8 * 1024 * 1024;
/// Registry of available tools.
#[derive(Clone, Debug, Default)]
pub struct ToolRegistry {
/// Tools by name.
tools: HashMap<String, ToolMetadata>,
/// Name-ordered snapshot handed to every provider request; rebuilt on
/// registration, shared afterwards.
snapshot: Arc<[ToolMetadata]>,
}
impl ToolRegistry {
/// An empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Register or replace a tool definition.
pub fn register(&mut self, meta: ToolMetadata) {
self.tools.insert(meta.name.clone(), meta);
let mut ordered: Vec<ToolMetadata> = self.tools.values().cloned().collect();
ordered.sort_by(|left, right| left.name.cmp(&right.name));
self.snapshot = ordered.into();
}
/// The registered tool, when present.
#[must_use]
pub fn get(&self, name: &str) -> Option<&ToolMetadata> {
self.tools.get(name)
}
/// The name-ordered tool definitions, for provider requests.
#[must_use]
pub fn snapshot(&self) -> Arc<[ToolMetadata]> {
Arc::clone(&self.snapshot)
}
/// A registry with the seven built-in coding tools and real schemas.
#[must_use]
pub fn with_builtins() -> Self {
let mut reg = Self::new();
for meta in builtin_tools() {
reg.register(meta);
}
reg
}
}
/// Built-in tool definitions with schemas used for argument validation.
#[must_use]
pub fn builtin_tools() -> Vec<ToolMetadata> {
let object = |required: &[&str], props: serde_json::Value| {
serde_json::json!({
"type": "object",
"required": required,
"properties": props,
})
};
vec![
ToolMetadata::new("read", "Read a bounded line window from a file.").with_schema(object(
&["path"],
serde_json::json!({
"path": {"type": "string"},
"offset": {"type": "integer"},
"limit": {"type": "integer"},
"max_bytes": {"type": "integer"},
}),
)),
ToolMetadata::new("write", "Atomically replace a file, creating parents.").with_schema(
object(
&["path", "content"],
serde_json::json!({
"path": {"type": "string"},
"content": {"type": "string"},
}),
),
),
ToolMetadata::new("edit", "Apply one exact, unambiguous replacement.").with_schema(object(
&["path", "old_string", "new_string"],
serde_json::json!({
"path": {"type": "string"},
"old_string": {"type": "string"},
"new_string": {"type": "string"},
}),
)),
ToolMetadata::new("ls", "List a directory deterministically.")
.with_schema(object(&[], serde_json::json!({"path": {"type": "string"}}))),
ToolMetadata::new("find", "Find directory entries matching a substring.").with_schema(
object(
&["pattern"],
serde_json::json!({
"path": {"type": "string"},
"pattern": {"type": "string"},
}),
),
),
ToolMetadata::new("grep", "Return bounded matching lines from a file.").with_schema(
object(
&["path", "query"],
serde_json::json!({
"path": {"type": "string"},
"query": {"type": "string"},
}),
),
),
ToolMetadata::new("bash", "Run a bounded cancellable shell command.").with_schema(object(
&["command"],
serde_json::json!({
"command": {"type": "string"},
"timeout_ms": {"type": "integer"},
}),
)),
]
}
/// Bundles the registry, the live session, and the durable writer so a
/// successful mutation can never be reported while its recording failed.
pub struct ToolSession {
registry: ToolRegistry,
/// Live session state; entries are appended as they are recorded.
pub session: Session,
writer: Option<SessionWriter>,
/// All file effects resolve relative to this directory.
pub working_dir: PathBuf,
config: Config,
cancel: CancelHandle,
/// What `read` and `write` last observed per file, used to reject edits
/// whose input describes an older version of the file.
observed: HashMap<PathBuf, FileVersion>,
}
/// Cheap identity of a file version: size plus modification time.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct FileVersion {
len: u64,
modified_ms: i64,
}
impl FileVersion {
fn of(path: &Path) -> Option<Self> {
let meta = fs::metadata(path).ok()?;
let modified_ms = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_millis();
Some(Self {
len: meta.len(),
modified_ms: i64::try_from(modified_ms).unwrap_or(i64::MAX),
})
}
}
impl ToolSession {
/// A tool session over built-in tools, no persistence.
pub fn new(working_dir: impl Into<PathBuf>) -> Self {
Self {
registry: ToolRegistry::with_builtins(),
session: Session::new(),
writer: None,
working_dir: working_dir.into(),
config: Config::default_valid(),
cancel: CancelHandle::new(),
observed: HashMap::new(),
}
}
/// Use `config` for effect timeouts and frame limits.
#[must_use]
pub fn with_config(mut self, config: Config) -> Self {
self.config = config;
self
}
/// The registered tool definitions, for provider requests.
#[must_use]
pub fn registered_tools(&self) -> Arc<[ToolMetadata]> {
self.registry.snapshot()
}
/// A handle that aborts in-flight and subsequent effects of this session.
#[must_use]
pub fn cancel_handle(&self) -> CancelHandle {
self.cancel.clone()
}
pub(crate) fn set_cancel_handle(&mut self, cancel: CancelHandle) {
self.cancel = cancel;
}
/// Continue an existing session, appending every recorded entry to its
/// durable writer.
pub fn with_session(
working_dir: impl Into<PathBuf>,
session: Session,
writer: SessionWriter,
) -> Self {
Self {
session,
writer: Some(writer),
..Self::new(working_dir)
}
}
/// Register or replace a tool definition.
pub fn register(&mut self, meta: ToolMetadata) {
self.registry.register(meta);
}
/// Validate arguments against the registered schema (required fields and
/// declared property types) before any effect can run.
///
/// # Errors
///
/// Returns [`SmithError::Tool`] for unknown tools, non-object input,
/// missing required fields, or mismatched field types.
pub fn validate(&self, name: &str, input: &serde_json::Value) -> Result<()> {
let meta = self.registry.get(name).ok_or_else(|| {
tool_err(
"TOOL_NOT_REGISTERED",
format!("tool '{name}' not registered"),
)
})?;
let obj = input
.as_object()
.ok_or_else(|| tool_err("INVALID_INPUT", "input must be a JSON object"))?;
let schema = &meta.input_schema;
for req in schema
.get("required")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
{
let key = req.as_str().unwrap_or_default();
if !obj.contains_key(key) {
return Err(tool_err(
"VALIDATION",
format!("missing required field '{key}' for tool '{name}'"),
));
}
}
if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
for (key, value) in obj {
if let Some(expected) = props
.get(key)
.and_then(|p| p.get("type"))
.and_then(|t| t.as_str())
&& !json_type_matches(expected, value)
{
{
return Err(tool_err(
"VALIDATION",
format!("field '{key}' for tool '{name}' must be of type {expected}"),
));
}
}
}
}
Ok(())
}
/// Validate, execute, and durably record one tool call.
///
/// Recording order is call entry, effect, result entry, so a recorded
/// call always precedes the effect it describes. A cancelled session
/// refuses to start an effect. If the effect runs but its result cannot
/// be recorded, the error states whether host state was already mutated,
/// instead of presenting an unrecorded success.
///
/// # Errors
///
/// Returns [`SmithError::Cancelled`] when the session was cancelled,
/// [`SmithError::RecordFailed`] when a completed effect could not be
/// recorded, or the effect's own error.
pub fn invoke(&mut self, name: &str, input: &serde_json::Value) -> Result<ToolOutcome> {
self.invoke_recorded(
ToolCallId::new(),
name,
input,
input,
str::to_string,
EffectFailure::ReturnError,
)
}
/// Execute trusted local arguments while recording only their redacted
/// representation.
///
/// `recorded_input` crosses persistence and provider boundaries;
/// `effect_input` exists only for the duration of the authorized local
/// effect. `redact_output` is applied before either the outcome or its
/// durable result can escape that boundary. Executed tool failures become
/// recorded outcomes so the model can observe and recover from them.
pub(crate) fn invoke_local(
&mut self,
call_id: ToolCallId,
name: &str,
recorded_input: &serde_json::Value,
effect_input: &serde_json::Value,
redact_output: impl Fn(&str) -> String,
) -> Result<ToolOutcome> {
self.invoke_recorded(
call_id,
name,
recorded_input,
effect_input,
redact_output,
EffectFailure::ReturnOutcome,
)
}
fn invoke_recorded(
&mut self,
call_id: ToolCallId,
name: &str,
recorded_input: &serde_json::Value,
effect_input: &serde_json::Value,
redact_output: impl Fn(&str) -> String,
failure: EffectFailure,
) -> Result<ToolOutcome> {
self.validate(name, recorded_input)?;
if self.cancel.is_cancelled() && failure == EffectFailure::ReturnError {
return Err(SmithError::Cancelled);
}
self.record(EntryContentRef::ToolCall {
name,
input: recorded_input,
call_id,
})?;
let effect = if self.cancel.is_cancelled() {
Err(SmithError::Cancelled)
} else {
execute_effect(
name,
effect_input,
&self.working_dir,
&self.config,
&self.cancel,
&mut self.observed,
)
};
let ok = effect.is_ok();
let output = match &effect {
Ok(text) => redact_output(text),
Err(err) => redact_output(&err.to_string()),
};
self.record(EntryContentRef::ToolResult {
ok,
output: &output,
call_id,
})
.map_err(|err| SmithError::RecordFailed {
tool: name.to_string(),
mutation_applied: ok && mutates_host(name),
message: err.to_string(),
})?;
let outcome = ToolOutcome {
call_id,
name: name.to_string(),
ok,
output,
timestamp_ms: now_ms(),
};
match effect {
Ok(_) => Ok(outcome),
Err(_) if failure == EffectFailure::ReturnOutcome => Ok(outcome),
Err(error) => Err(error),
}
}
/// Append `content` after the selected entry and durably record it
/// first; a failed write leaves the session unchanged.
pub(crate) fn record<'a>(&mut self, content: impl Into<EntryContentRef<'a>>) -> Result<Entry> {
let writer = self.writer.as_mut();
self.session
.append_with(content, |entry, bytes| {
writer.map_or(Ok(()), |writer| writer.append_entry(entry, bytes))
})
.copied()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EffectFailure {
ReturnError,
ReturnOutcome,
}
/// A recorded tool outcome returned to the caller.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ToolOutcome {
/// Tool call ID.
pub call_id: ToolCallId,
/// Tool name.
pub name: String,
/// Whether the effect succeeded.
pub ok: bool,
/// Recorded output or error message.
pub output: String,
/// Timestamp of outcome.
pub timestamp_ms: i64,
}
fn tool_err(code: &str, message: impl Into<String>) -> SmithError {
SmithError::Tool {
code: code.to_string(),
message: message.into(),
}
}
fn io_tool_err(code: &'static str) -> impl Fn(std::io::Error) -> SmithError {
move |e| tool_err(code, e.to_string())
}
fn json_type_matches(expected: &str, value: &serde_json::Value) -> bool {
match expected {
"string" => value.is_string(),
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64(),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"object" => value.is_object(),
"null" => value.is_null(),
_ => true,
}
}
fn resolve_path(input_path: &str, working_dir: &Path) -> PathBuf {
let p = Path::new(input_path);
if p.is_absolute() {
p.to_path_buf()
} else {
working_dir.join(p)
}
}
/// Execute a built-in file effect. All outputs are bounded and deterministic;
/// filesystem errors are surfaced, never converted to empty success.
/// Tools whose successful effect changes host state.
fn mutates_host(name: &str) -> bool {
matches!(name, "write" | "edit" | "bash")
}
fn execute_effect(
name: &str,
input: &serde_json::Value,
working_dir: &Path,
config: &Config,
cancel: &CancelHandle,
observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
match name {
"bash" => run_bash(input, working_dir, config.tool_timeout_ms, cancel),
"read" => read_effect(input, resolve_path(path_str, working_dir), observed),
"write" => write_effect(input, resolve_path(path_str, working_dir), observed),
"edit" => edit_effect(input, resolve_path(path_str, working_dir), observed),
"ls" => {
let dir = directory_or(path_str, working_dir);
if !dir.is_dir() {
return Err(tool_err(
"LS_ERROR",
format!("{} is not a directory", dir.display()),
));
}
let names = walk_relative(&dir, Some(1), config, cancel, "LS_ERROR")?;
Ok(bounded_join(names, MAX_LIST_ENTRIES))
}
"find" => {
let pattern = input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or_default();
let root = directory_or(path_str, working_dir);
if !root.is_dir() {
return Err(tool_err(
"FIND_ERROR",
format!("{} is not a directory", root.display()),
));
}
let names = walk_relative(&root, None, config, cancel, "FIND_ERROR")?
.into_iter()
.filter(|name| pattern.is_empty() || name.contains(pattern))
.collect();
Ok(bounded_join(names, MAX_LIST_ENTRIES))
}
"grep" => {
let query = input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or_default();
let target = directory_or(path_str, working_dir);
if target.is_dir() {
grep_tree(&target, query, config, cancel)
} else {
grep_bounded(&target, query)
}
}
_ => Err(tool_err(
"TOOL_NOT_IMPLEMENTED",
format!("tool '{name}' has no built-in effect"),
)),
}
}
/// Read a bounded window and remember the version that was shown.
fn read_effect(
input: &serde_json::Value,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
let count = |key: &str| {
input
.get(key)
.and_then(serde_json::Value::as_u64)
.map(|value| usize::try_from(value).unwrap_or(usize::MAX))
};
let text = read_bounded(
&path,
count("offset").unwrap_or(0),
count("limit").unwrap_or(usize::MAX),
count("max_bytes").map_or(DEFAULT_OUTPUT_CAP, |bytes| bytes.min(HARD_OUTPUT_CAP)),
)?;
remember(path, observed);
Ok(text)
}
/// Replace a file atomically and remember the version just written.
fn write_effect(
input: &serde_json::Value,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
let content = input
.get("content")
.and_then(|value| value.as_str())
.ok_or_else(|| tool_err("VALIDATION", "write requires string 'content'"))?;
write_atomic(&path, content.as_bytes())?;
remember(path, observed);
Ok(format!("written {} bytes", content.len()))
}
/// Apply an exact replacement, refusing input that describes an older
/// version of the file.
///
/// An edit describes a file the caller has seen. If the file moved on since
/// then, applying the replacement would silently discard the newer content.
fn edit_effect(
input: &serde_json::Value,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
let text = |key: &str| input.get(key).and_then(|v| v.as_str()).unwrap_or_default();
if let Some(seen) = observed.get(&path)
&& FileVersion::of(&path) != Some(*seen)
{
return Err(tool_err(
"STALE_INPUT",
format!(
"{} changed since it was last read; read it again before editing",
path.display()
),
));
}
let result = edit_exact(&path, text("old_string"), text("new_string"))?;
remember(path, observed);
Ok(result)
}
/// Record the current version of `path` as the one the caller has seen.
fn remember(path: PathBuf, observed: &mut HashMap<PathBuf, FileVersion>) {
if let Some(version) = FileVersion::of(&path) {
observed.insert(path, version);
}
}
/// The requested path, or the invocation directory when none was given.
fn directory_or(path_str: &str, working_dir: &Path) -> PathBuf {
if path_str.is_empty() {
working_dir.to_path_buf()
} else {
resolve_path(path_str, working_dir)
}
}
/// Ignore-aware, deterministic walk of `root`.
///
/// Traversal order is sorted by path, and configured ignore files and hidden
/// entries are honored, so identical trees always produce identical results.
fn walker(root: &Path, max_depth: Option<usize>, config: &Config) -> ignore::Walk {
let respect = config.respect_ignore_files;
let mut builder = ignore::WalkBuilder::new(root);
builder
.max_depth(max_depth)
.hidden(!config.include_hidden)
.ignore(respect)
.git_ignore(respect)
.git_global(respect)
.git_exclude(respect)
.parents(respect)
// Honor ignore files even outside a checkout, so listing behavior
// does not depend on whether a VCS happens to be present.
.require_git(false)
.sort_by_file_path(std::cmp::Ord::cmp);
builder.build()
}
/// Paths under `root`, relative to it, bounded and cancellable.
fn walk_relative(
root: &Path,
max_depth: Option<usize>,
config: &Config,
cancel: &CancelHandle,
code: &'static str,
) -> std::result::Result<Vec<String>, SmithError> {
let mut names = Vec::new();
for found in walker(root, max_depth, config) {
if cancel.is_cancelled() {
return Err(SmithError::Cancelled);
}
let found = found.map_err(|e| tool_err(code, e.to_string()))?;
if found.depth() == 0 {
continue;
}
names.push(
found
.path()
.strip_prefix(root)
.unwrap_or_else(|_| found.path())
.to_string_lossy()
.into_owned(),
);
if names.len() >= MAX_LIST_ENTRIES {
break;
}
}
Ok(names)
}
/// Search every non-ignored file under `root` for `query`.
///
/// Matches are reported as `relative/path:line:text`, bounded by match count
/// and total output, and unreadable or binary files are skipped rather than
/// failing the whole search.
fn grep_tree(
root: &Path,
query: &str,
config: &Config,
cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
let mut matches: Vec<String> = Vec::new();
let mut total_bytes = 0usize;
for found in walker(root, None, config) {
if cancel.is_cancelled() {
return Err(SmithError::Cancelled);
}
let found = found.map_err(|e| tool_err("GREP_ERROR", e.to_string()))?;
if !found.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
let relative = found
.path()
.strip_prefix(root)
.unwrap_or_else(|_| found.path())
.to_string_lossy()
.into_owned();
let Ok(file) = fs::File::open(found.path()) else {
continue;
};
let mut reader = BufReader::with_capacity(64 * 1024, file);
let mut line = Vec::new();
let mut line_no = 0usize;
loop {
line.clear();
match reader.read_until(b'\n', &mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if line.contains(&0) {
break; // binary content
}
line_no += 1;
let text = String::from_utf8_lossy(&line).trim_end().to_string();
if !text.contains(query) {
continue;
}
let rendered = format!("{relative}:{line_no}:{text}");
if matches.len() >= MAX_GREP_MATCHES
|| total_bytes + rendered.len() > DEFAULT_OUTPUT_CAP
{
matches.push("<truncated>".to_string());
return Ok(matches.join("\n"));
}
total_bytes += rendered.len();
matches.push(rendered);
}
}
Ok(matches.join("\n"))
}
/// Run one bounded, cancellable shell command in the invocation directory.
fn run_bash(
input: &serde_json::Value,
working_dir: &Path,
timeout_ms: u64,
cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
let command = input
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| tool_err("VALIDATION", "bash requires string 'command'"))?;
let requested = input
.get("timeout_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(timeout_ms);
let result = crate::bash::execute(
command,
working_dir.to_str(),
requested.min(timeout_ms),
DEFAULT_OUTPUT_CAP,
cancel,
)?;
if result.cancelled {
return Err(SmithError::Cancelled);
}
let mut output = result.stdout;
if !result.stderr.is_empty() {
if !output.is_empty() {
output.push('\n');
}
output.push_str("[stderr] ");
output.push_str(&result.stderr);
}
if result.timed_out {
return Err(tool_err(
"BASH_TIMEOUT",
format!("timed out after {requested} ms: {output}"),
));
}
match result.exit_code {
Some(0) => Ok(output),
Some(code) => Err(tool_err("BASH_EXIT", format!("exit {code}: {output}"))),
None => Err(tool_err(
"BASH_KILLED",
format!("terminated without exit status: {output}"),
)),
}
}
/// Read a bounded line window without materializing the whole file.
fn read_bounded(
path: &Path,
offset: usize,
limit: usize,
max_bytes: usize,
) -> std::result::Result<String, SmithError> {
let file = fs::File::open(path).map_err(io_tool_err("READ_ERROR"))?;
let mut reader = BufReader::with_capacity(64 * 1024, file);
// Binary sniff on the first bytes.
let sniff = reader.fill_buf().map_err(io_tool_err("READ_ERROR"))?;
if sniff[..sniff.len().min(8192)].contains(&0) {
return Err(tool_err(
"BINARY_CONTENT",
"file contains NUL bytes; binary content is unsupported",
));
}
let mut out = String::new();
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
let mut line_no = 0usize;
let mut bytes_out = 0usize;
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) => return Err(io_tool_err("READ_ERROR")(e)),
}
line_no += 1;
if line_no <= offset {
continue;
}
let emit = if limit == usize::MAX {
true
} else {
(line_no - offset) <= limit
};
if !emit {
break;
}
if bytes_out + buf.len() > max_bytes {
break; // keep fitting prefix only
}
bytes_out += buf.len();
out.push_str(&String::from_utf8_lossy(&buf));
if bytes_out >= max_bytes {
break;
}
}
Ok(out)
}
/// Atomically replace `path` with `content`: temp file in the same
/// directory, synced, then renamed over the destination.
fn write_atomic(path: &Path, content: &[u8]) -> std::result::Result<(), SmithError> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
fs::create_dir_all(parent).map_err(io_tool_err("WRITE_MKDIR"))?;
}
let tmp = temp_sibling(path);
let write = |tmp: &Path| -> std::io::Result<()> {
let mut f = fs::File::create(tmp)?;
f.write_all(content)?;
f.sync_all()?;
Ok(())
};
write(&tmp).map_err(io_tool_err("WRITE_ERROR"))?;
fs::rename(&tmp, path).map_err(|e| {
let _ = fs::remove_file(&tmp);
io_tool_err("WRITE_PUBLISH")(e)
})?;
Ok(())
}
/// A hidden sibling path used for atomic publication.
fn temp_sibling(path: &Path) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let name = path.file_name().map_or_else(
|| "file".to_string(),
|name| name.to_string_lossy().into_owned(),
);
path.with_file_name(format!(".{name}.tmp-{}-{n}", std::process::id()))
}
/// Apply exactly one unambiguous replacement, publishing atomically.
fn edit_exact(path: &Path, old: &str, new: &str) -> std::result::Result<String, SmithError> {
let meta = fs::metadata(path).map_err(io_tool_err("EDIT_READ_ERROR"))?;
if meta.len() > MAX_EDIT_FILE_BYTES {
return Err(tool_err(
"FILE_TOO_LARGE",
"edit input exceeds the file size limit",
));
}
let bytes = fs::read(path).map_err(io_tool_err("EDIT_READ_ERROR"))?;
let content = String::from_utf8(bytes)
.map_err(|_| tool_err("EDIT_NOT_UTF8", "edit supports UTF-8 files only"))?;
let count = content.matches(old).count();
if count == 0 {
return Err(tool_err(
"EDIT_NOT_FOUND",
"old_string not found; file unchanged",
));
}
if count > 1 {
return Err(tool_err(
"EDIT_AMBIGUOUS",
format!("old_string matches {count} locations; file unchanged"),
));
}
let updated = content.replacen(old, new, 1);
write_atomic(path, updated.as_bytes())?;
Ok("edited".to_string())
}
/// Deterministic, bounded listing output.
fn bounded_join(mut names: Vec<String>, cap: usize) -> String {
names.truncate(cap);
let mut out = names.join("\n");
let max = DEFAULT_OUTPUT_CAP;
if out.len() > max {
out.truncate(max);
out.push_str("\n<truncated>");
}
out
}
/// Stream matching lines with bounded memory and result count.
fn grep_bounded(path: &Path, query: &str) -> std::result::Result<String, SmithError> {
let file = fs::File::open(path).map_err(io_tool_err("GREP_READ_ERROR"))?;
let reader = BufReader::with_capacity(64 * 1024, file);
let mut matches: Vec<String> = Vec::new();
let mut total_bytes = 0usize;
for line in reader.lines() {
let line = line.map_err(io_tool_err("GREP_READ_ERROR"))?;
if line.contains(query) {
if matches.len() >= MAX_GREP_MATCHES || total_bytes + line.len() > DEFAULT_OUTPUT_CAP {
matches.push("<truncated>".to_string());
break;
}
total_bytes += line.len();
matches.push(line);
}
}
Ok(matches.join("\n"))
}
/// Wall-clock milliseconds since the Unix epoch, saturating on overflow.
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
})
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::panic,
reason = "tests may panic on invariant violations"
)]
mod tests {
use super::*;
use crate::session::EntryContent;
use crate::session::EntryKind;
use std::path::PathBuf;
fn workdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("smith_tools_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn listing_and_searching_respect_ignore_rules_and_hidden_entries() {
let dir = workdir("ignore_rules");
std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
std::fs::write(dir.join("keep.txt"), "needle here\n").unwrap();
std::fs::write(dir.join("ignored.txt"), "needle here\n").unwrap();
std::fs::write(dir.join(".hidden.txt"), "needle here\n").unwrap();
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested/deep.txt"), "needle deep\n").unwrap();
let mut ts = ToolSession::new(&dir);
// find walks recursively and drops ignored and hidden paths.
let found = ts
.invoke("find", &serde_json::json!({"pattern": ""}))
.unwrap()
.output;
assert_eq!(found, "keep.txt\nnested\nnested/deep.txt");
// ls stays one level deep, with the same filtering.
let listed = ts.invoke("ls", &serde_json::json!({})).unwrap().output;
assert_eq!(listed, "keep.txt\nnested");
// grep searches the tree and locates each match.
let hits = ts
.invoke("grep", &serde_json::json!({"path": "", "query": "needle"}))
.unwrap()
.output;
assert_eq!(
hits,
"keep.txt:1:needle here\nnested/deep.txt:1:needle deep"
);
// Configuration decides: without ignore rules, everything shows up.
let mut config = Config::default_valid();
config.respect_ignore_files = false;
config.include_hidden = true;
let mut permissive = ToolSession::new(&dir).with_config(config);
let found = permissive
.invoke("find", &serde_json::json!({"pattern": ".txt"}))
.unwrap()
.output;
assert_eq!(found, ".hidden.txt\nignored.txt\nkeep.txt\nnested/deep.txt");
let hits = permissive
.invoke(
"grep",
&serde_json::json!({"path": "", "query": "needle here"}),
)
.unwrap()
.output;
assert_eq!(
hits,
".hidden.txt:1:needle here\nignored.txt:1:needle here\nkeep.txt:1:needle here"
);
}
#[test]
fn edit_rejects_input_that_describes_an_older_version_of_the_file() {
let dir = workdir("stale_edit");
let target = dir.join("doc.txt");
std::fs::write(&target, "alpha one\n").unwrap();
let mut ts = ToolSession::new(&dir);
ts.invoke("read", &serde_json::json!({"path": "doc.txt"}))
.unwrap();
// Somebody else rewrites the file after the read; same length, so only the mtime tells.
std::fs::write(&target, "alpha two\n").unwrap();
// inject: pin the rewrite's mtime past the read instead of sleeping for a clock tick.
let later = std::fs::metadata(&target).unwrap().modified().unwrap()
+ std::time::Duration::from_secs(60);
std::fs::File::options()
.write(true)
.open(&target)
.unwrap()
.set_modified(later)
.unwrap();
let err = ts
.invoke(
"edit",
&serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
)
.unwrap_err();
assert_eq!(err.code(), "STALE_INPUT");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "alpha two\n");
// Re-reading refreshes the observed version and unblocks the edit.
ts.invoke("read", &serde_json::json!({"path": "doc.txt"}))
.unwrap();
ts.invoke(
"edit",
&serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
)
.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "beta two\n");
}
#[test]
fn bash_effect_runs_in_the_workdir_and_is_recorded() {
let dir = workdir("bash_effect");
let mut ts = ToolSession::new(&dir);
let outcome = ts
.invoke(
"bash",
&serde_json::json!({"command": "printf hi > made.txt; printf done"}),
)
.unwrap();
assert!(outcome.ok);
assert_eq!(outcome.output, "done");
assert_eq!(std::fs::read_to_string(dir.join("made.txt")).unwrap(), "hi");
// A recorded call always precedes its result.
let kinds: Vec<&str> = ts
.session
.active_branch()
.entries()
.iter()
.map(|e| match e.kind {
EntryKind::ToolCall { .. } => "call",
EntryKind::ToolResult { .. } => "result",
_ => "other",
})
.collect();
assert_eq!(kinds, vec!["call", "result"]);
}
#[test]
fn failing_and_timing_out_commands_report_explicitly() {
let dir = workdir("bash_failures");
let mut ts = ToolSession::new(&dir);
let err = ts
.invoke("bash", &serde_json::json!({"command": "exit 3"}))
.unwrap_err();
assert_eq!(err.code(), "BASH_EXIT");
let err = ts
.invoke(
"bash",
&serde_json::json!({"command": "sleep 5", "timeout_ms": 150}),
)
.unwrap_err();
assert_eq!(err.code(), "BASH_TIMEOUT");
// Both failures stay durable evidence, recorded as failed results.
let failed = ts
.session
.active_branch()
.entries()
.iter()
.filter(|e| matches!(e.kind, EntryKind::ToolResult { ok: false, .. }))
.count();
assert_eq!(failed, 2);
}
#[test]
fn cancellation_stops_effects_before_they_start() {
let dir = workdir("cancel_effects");
let mut ts = ToolSession::new(&dir);
let cancel = ts.cancel_handle();
cancel.cancel();
let err = ts
.invoke(
"write",
&serde_json::json!({"path": "never.txt", "content": "x"}),
)
.unwrap_err();
assert_eq!(err.code(), "CANCELLED");
assert!(!dir.join("never.txt").exists());
// A refused call records nothing.
assert!(ts.session.active_branch().entries().is_empty());
}
#[test]
fn cancelling_a_running_command_aborts_it() {
let dir = workdir("cancel_running");
let mut ts = ToolSession::new(&dir);
let cancel = ts.cancel_handle();
let signal = crate::process_signal::ProcessSignal::new();
let command = format!("{}; sleep 30", signal.raise());
let running = std::thread::spawn(move || {
ts.invoke(
"bash",
&serde_json::json!({"command": command, "timeout_ms": 30_000}),
)
});
// synchronize: cancel once the command reports it runs, never after a guessed delay.
let mut socket = signal.wait();
cancel.cancel();
// Before the join: a survivor would hold the pipes and stall it.
assert!(
crate::process_signal::closed(&mut socket),
"command kept running"
);
let err = running.join().unwrap().unwrap_err();
assert_eq!(err.code(), "CANCELLED");
}
#[test]
fn unrecordable_result_after_a_mutation_reports_the_mutation() {
let dir = workdir("record_failed");
let session_path = dir.join("session.smh");
let mut cfg = Config::default_valid();
cfg.max_frame_bytes = 1024;
let opened = crate::store::open_session(&session_path, cfg.clone()).unwrap();
let mut ts =
ToolSession::with_session(&dir, opened.session, opened.writer).with_config(cfg);
// The call entry fits, the 5 KiB result does not: the effect already
// ran, so the error has to admit the mutation.
let err = ts
.invoke(
"bash",
&serde_json::json!({"command": "printf 'x%.0s' $(seq 5000)"}),
)
.unwrap_err();
assert_eq!(err.code(), "RECORD_FAILED");
assert!(matches!(
err,
SmithError::RecordFailed {
mutation_applied: true,
..
}
));
// Only the durably recorded call is part of the live session.
let kinds: Vec<EntryKind> = ts
.session
.active_branch()
.entries()
.iter()
.map(|e| e.kind)
.collect();
assert!(matches!(kinds.as_slice(), [EntryKind::ToolCall { .. }]));
}
#[test]
fn write_requires_string_content_and_leaves_existing_file_untouched() {
let dir = workdir("write_schema");
let target = dir.join("data.txt");
std::fs::write(&target, "original").unwrap();
let mut ts = ToolSession::new(&dir);
let err = ts
.invoke("write", &serde_json::json!({"path": "data.txt"}))
.unwrap_err();
assert_eq!(err.code(), "VALIDATION");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
let err = ts
.invoke(
"write",
&serde_json::json!({"path": "data.txt", "content": 42}),
)
.unwrap_err();
assert_eq!(err.code(), "VALIDATION");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_creates_missing_parents_and_leaves_no_temp_files() {
let dir = workdir("write_parents");
let mut ts = ToolSession::new(&dir);
ts.invoke(
"write",
&serde_json::json!({"path": "a/b/c.txt", "content": "deep"}),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("a/b/c.txt")).unwrap(),
"deep"
);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(std::result::Result::ok)
.flat_map(|e| {
std::fs::read_dir(e.path())
.unwrap()
.filter_map(std::result::Result::ok)
.map(|x| x.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
})
.filter(|n| n.contains(".tmp-"))
.collect();
assert!(
leftovers.is_empty(),
"temp files left behind: {leftovers:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edit_rejects_ambiguous_and_missing_matches_without_changes() {
let dir = workdir("edit_amb");
let target = dir.join("f.txt");
std::fs::write(&target, "a a").unwrap();
let mut ts = ToolSession::new(&dir);
let err = ts
.invoke(
"edit",
&serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
)
.unwrap_err();
assert_eq!(err.code(), "EDIT_AMBIGUOUS");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
let err = ts
.invoke(
"edit",
&serde_json::json!({"path": "f.txt", "old_string": "NOT_PRESENT", "new_string": "b"}),
)
.unwrap_err();
assert_eq!(err.code(), "EDIT_NOT_FOUND");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
// Unambiguous edit succeeds.
ts.invoke(
"edit",
&serde_json::json!({"path": "f.txt", "old_string": "a a", "new_string": "b"}),
)
.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "b");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_supports_line_windows_byte_caps_and_binary_detection() {
let dir = workdir("read_caps");
let mut body = String::new();
for i in 0..100 {
use std::fmt::Write as _;
let _ = writeln!(body, "line-{i}");
}
std::fs::write(dir.join("lines.txt"), &body).unwrap();
let mut ts = ToolSession::new(&dir);
let out = ts
.invoke(
"read",
&serde_json::json!({"path": "lines.txt", "offset": 2, "limit": 3}),
)
.unwrap();
assert_eq!(out.output, "line-2\nline-3\nline-4\n");
let capped = ts
.invoke(
"read",
&serde_json::json!({"path": "lines.txt", "max_bytes": 20}),
)
.unwrap();
assert!(capped.output.len() <= 20);
std::fs::write(dir.join("bin.dat"), [0x62, 0x00, 0x62]).unwrap();
let err = ts
.invoke("read", &serde_json::json!({"path": "bin.dat"}))
.unwrap_err();
assert_eq!(err.code(), "BINARY_CONTENT");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ls_and_find_are_sorted_and_surface_errors() {
let dir = workdir("ls_find");
for name in ["zeta", "alpha", "mid"] {
std::fs::write(dir.join(name), "").unwrap();
}
let mut ts = ToolSession::new(&dir);
let out = ts.invoke("ls", &serde_json::json!({})).unwrap();
assert_eq!(out.output, "alpha\nmid\nzeta");
let found = ts
.invoke("find", &serde_json::json!({"pattern": "a"}))
.unwrap();
assert_eq!(found.output, "alpha\nzeta");
let err = ts
.invoke(
"find",
&serde_json::json!({"path": "missing-dir", "pattern": "x"}),
)
.unwrap_err();
assert_eq!(err.code(), "FIND_ERROR");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn grep_returns_bounded_matches() {
let dir = workdir("grep");
std::fs::write(
dir.join("log.txt"),
"hit one\nmiss\nhit two\nmiss\nhit three\n",
)
.unwrap();
let mut ts = ToolSession::new(&dir);
let out = ts
.invoke(
"grep",
&serde_json::json!({"path": "log.txt", "query": "hit"}),
)
.unwrap();
assert_eq!(out.output, "hit one\nhit two\nhit three");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unregistered_tools_and_non_object_input_are_rejected() {
let dir = workdir("registry");
let mut ts = ToolSession::new(&dir);
let err = ts.invoke("rm", &serde_json::json!({})).unwrap_err();
assert_eq!(err.code(), "TOOL_NOT_REGISTERED");
let err = ts
.invoke("read", &serde_json::json!("not-an-object"))
.unwrap_err();
assert_eq!(err.code(), "INVALID_INPUT");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_effects_are_recorded_as_failures() {
let dir = workdir("record_fail");
let mut ts = ToolSession::new(&dir);
let res = ts.invoke("read", &serde_json::json!({"path": "nope.txt"}));
assert!(res.is_err());
// Two entries: call + failed result, both recorded.
assert_eq!(ts.session.active_branch().entries().len(), 2);
let last = &ts.session.active_branch().entries()[1];
match ts.session.content(last).unwrap() {
EntryContent::ToolResult { ok, output, .. } => {
assert!(!ok);
assert!(output.contains("READ_ERROR"));
}
other => panic!("unexpected {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
}