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::metadata::{ToolMutationMetadata, encode as encode_metadata};
use crate::session::{Entry, EntryContentRef, Session, TOOL_MUTATION_KIND};
use crate::store::SessionWriter;
use smith::config::Config;
use smith::error::{MutationState, Result, SmithError};
use smith::id::ToolCallId;
use smith::structured::{
Kind, SchemaError, StructuredData, StructuredValue, ToolArguments, ToolSchema,
};
use smith::tool::{CancelHandle, ToolMetadata};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const HARD_OUTPUT_CAP: usize = 256 * 1024;
const DEFAULT_OUTPUT_CAP: usize = 64 * 1024;
const LIST_ENTRIES_MAX: usize = 1000;
const GREP_MATCHES_MAX: usize = 200;
const GREP_LINE_BYTES_MAX: usize = DEFAULT_OUTPUT_CAP;
const EDIT_FILE_BYTES_MAX: usize = 8 * 1024 * 1024;
/// Read one LF-delimited line without materializing more than `bytes_max` bytes.
fn read_line_bounded(
reader: &mut impl BufRead,
line: &mut Vec<u8>,
bytes_max: usize,
) -> std::io::Result<bool> {
#[expect(
clippy::disallowed_methods,
reason = "Take caps one line before overflow is reported"
)]
let bytes_read = reader
.take((bytes_max + 1) as u64)
.read_until(b'\n', line)?;
if bytes_read == 0 {
return Ok(false);
}
let line_ended = line.ends_with(b"\n") || reader.fill_buf()?.is_empty();
if line.len() > bytes_max || !line_ended {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("line exceeds {bytes_max} bytes"),
));
}
Ok(true)
}
/// 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> {
vec![
ToolMetadata::new("read", "Read a bounded line window from a file.").with_schema(
ToolSchema::object(
&[
("path", "string"),
("offset", "integer"),
("limit", "integer"),
("max_bytes", "integer"),
],
&["path"],
),
),
ToolMetadata::new("write", "Atomically replace a file, creating parents.").with_schema(
ToolSchema::object(
&[("path", "string"), ("content", "string")],
&["path", "content"],
),
),
ToolMetadata::new("edit", "Apply one exact, unambiguous replacement.").with_schema(
ToolSchema::object(
&[
("path", "string"),
("old_string", "string"),
("new_string", "string"),
],
&["path", "old_string", "new_string"],
),
),
ToolMetadata::new("ls", "List a directory deterministically.")
.with_schema(ToolSchema::object(&[("path", "string")], &[])),
ToolMetadata::new("find", "Find directory entries matching a substring.").with_schema(
ToolSchema::object(&[("path", "string"), ("pattern", "string")], &["pattern"]),
),
ToolMetadata::new("grep", "Return bounded matching lines from a file.").with_schema(
ToolSchema::object(
&[("path", "string"), ("query", "string")],
&["path", "query"],
),
),
ToolMetadata::new("bash", "Run a bounded cancellable shell command.").with_schema(
ToolSchema::object(
&[("command", "string"), ("timeout_ms", "integer")],
&["command"],
),
),
]
}
type PersistEntry = dyn FnMut(&Entry, &[u8]) -> Result<()> + Send;
enum Persistence {
None,
Direct(SessionWriter),
Worker(Box<PersistEntry>),
}
/// Bundles the registry, the live session, and durable persistence so a
/// successful mutation cannot be reported while recording failed.
pub struct ToolSession {
registry: ToolRegistry,
/// Live session state; entries are appended as they are recorded.
pub session: Session,
persistence: Persistence,
/// 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>,
/// Completion persistence failed; no further host effects may begin.
recording_failed: bool,
#[cfg(test)]
test_effect_control: TestEffectControl,
}
/// 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(),
persistence: Persistence::None,
working_dir: working_dir.into(),
config: Config::default_valid(),
cancel: CancelHandle::new(),
observed: HashMap::new(),
recording_failed: false,
#[cfg(test)]
test_effect_control: TestEffectControl::default(),
}
}
/// 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;
}
#[cfg(test)]
const fn cancel_at_checkpoint(&mut self, checkpoint: EffectCheckpoint) {
self.test_effect_control.cancel_at = Some(checkpoint);
}
#[cfg(test)]
const fn timeout_at_checkpoint(&mut self, checkpoint: EffectCheckpoint) {
self.test_effect_control.timeout_at = Some(checkpoint);
}
/// 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,
persistence: Persistence::Direct(writer),
..Self::new(working_dir)
}
}
/// Keep persistence on an external storage worker.
#[must_use]
pub fn with_persist(
working_dir: impl Into<PathBuf>,
session: Session,
persist: impl FnMut(&Entry, &[u8]) -> Result<()> + Send + 'static,
) -> Self {
Self {
session,
persistence: Persistence::Worker(Box::new(persist)),
..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<'a, Input: StructuredValue<'a>>(&self, name: &str, input: Input) -> Result<()> {
let meta = self.registry.get(name).ok_or_else(|| {
tool_error(
smith::error::ToolCode::ToolNotRegistered,
format!("tool '{name}' not registered"),
)
})?;
meta.input_schema.validate(input).map_err(|error| {
let (code, message) = match error {
SchemaError::InputNotObject => (
smith::error::ToolCode::InvalidInput,
"input must be a JSON object".to_string(),
),
SchemaError::MissingField(field) => (
smith::error::ToolCode::Validation,
format!("missing required field '{field}' for tool '{name}'"),
),
SchemaError::FieldType { field, expected } => (
smith::error::ToolCode::Validation,
format!("field '{field}' for tool '{name}' must be of type {expected}"),
),
SchemaError::Access(error) => {
(smith::error::ToolCode::Validation, error.to_string())
}
};
tool_error(code, message)
})
}
/// 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: &ToolArguments) -> 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: &ToolArguments,
effect_input: &ToolArguments,
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: &ToolArguments,
effect_input: &ToolArguments,
redact_output: impl Fn(&str) -> String,
failure: EffectFailure,
) -> Result<ToolOutcome> {
self.validate(name, recorded_input.view())?;
if self.recording_failed {
return Err(SmithError::RecordFailed {
tool: name.to_string(),
mutation_state: MutationState::Unknown,
message: "a prior completion recording failure stopped further effects".to_string(),
});
}
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,
#[cfg(test)]
self.test_effect_control,
)
};
let ok = effect.is_ok();
let mutation_state = mutation_state(name, &effect);
let output = match &effect {
Ok(text) => redact_output(text),
Err(error) => redact_output(&error.to_string()),
};
let detail = mutation_detail(call_id, mutation_state)?;
let mut completion = || -> Result<()> {
self.record(EntryContentRef::ToolResult {
ok,
output: &output,
call_id,
})?;
self.record(EntryContentRef::Meta {
kind: TOOL_MUTATION_KIND,
detail: &detail,
})?;
Ok(())
};
if let Err(error) = completion() {
self.recording_failed = true;
self.cancel.cancel();
return Err(SmithError::RecordFailed {
tool: name.to_string(),
mutation_state,
message: error.to_string(),
});
}
let outcome = ToolOutcome {
call_id,
name: name.to_string(),
ok,
output,
timestamp_ms: now_ms(),
mutation_state,
};
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 persistence = &mut self.persistence;
let entry = self
.session
.append_with(content, |entry, bytes| match persistence {
Persistence::None => Ok(()),
Persistence::Direct(writer) => writer.append_entry(entry, bytes),
Persistence::Worker(persist) => persist(entry, bytes),
})
.copied()?;
if entry.is_compaction() {
self.session.retain_compacted_context();
}
Ok(entry)
}
}
#[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,
/// Durable best-known host mutation outcome.
pub mutation_state: MutationState,
}
fn mutation_detail(call_id: ToolCallId, mutation_state: MutationState) -> Result<StructuredData> {
encode_metadata(&ToolMutationMetadata::new(call_id, mutation_state))
}
fn tool_error(code: smith::error::ToolCode, message: impl Into<String>) -> SmithError {
SmithError::Tool {
code,
message: message.into(),
}
}
fn io_tool_error(code: smith::error::ToolCode) -> impl Fn(std::io::Error) -> SmithError {
move |e| tool_error(code, e.to_string())
}
fn input_string<'a>(input: &'a ToolArguments, key: &str) -> Result<Option<&'a str>> {
let Some(value) = input.view().field(key).map_err(structured_tool_error)? else {
return Ok(None);
};
match value.kind().map_err(structured_tool_error)? {
Kind::String(value) => Ok(Some(value)),
_ => Ok(None),
}
}
fn input_unsigned(input: &ToolArguments, key: &str) -> Result<Option<u64>> {
let Some(value) = input.view().field(key).map_err(structured_tool_error)? else {
return Ok(None);
};
match value.kind().map_err(structured_tool_error)? {
Kind::Unsigned(value) => Ok(Some(value)),
_ => Ok(None),
}
}
fn structured_tool_error(error: smith::structured::AccessError) -> SmithError {
tool_error(smith::error::ToolCode::Validation, error.to_string())
}
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.
/// Infer only what effect-boundary evidence proves.
///
/// A shell's exit status says nothing about arbitrary commands it may have
/// executed, so every spawned shell is `Unknown`; only spawn failure proves
/// `NotApplied`. File publication and parent creation provide direct evidence.
fn mutation_state(name: &str, effect: &std::result::Result<String, SmithError>) -> MutationState {
match name {
"bash" => match effect {
Err(
SmithError::Cancelled
| SmithError::Tool {
code: smith::error::ToolCode::BashSpawn,
..
},
) => MutationState::NotApplied,
_ => MutationState::Unknown,
},
"write" | "edit" => match effect {
Ok(_) => MutationState::Applied,
Err(SmithError::Tool { code, .. })
if matches!(code.as_str(), "CANCELLED_APPLIED" | "TOOL_TIMEOUT_APPLIED") =>
{
MutationState::Applied
}
Err(SmithError::Cancelled) => MutationState::NotApplied,
Err(_) => MutationState::Unknown,
},
_ => MutationState::NotApplied,
}
}
/// Cooperative checkpoints placed at bounded filesystem work boundaries.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EffectCheckpoint {
Start,
TraversalEntry,
ReadChunk,
GrepLine,
EditReadChunk,
WriteChunk,
ParentCreated,
BeforePublish,
AfterPublish,
}
#[cfg(test)]
#[derive(Clone, Copy, Default)]
struct TestEffectControl {
cancel_at: Option<EffectCheckpoint>,
timeout_at: Option<EffectCheckpoint>,
}
/// Cooperative cancellation and deadline checks around filesystem chunks.
///
/// Standard filesystem calls can block without a portable interruption API.
/// Smith therefore checks before and after bounded reads and writes, and
/// immediately before publication. A cancellation observed after `rename`
/// reports that the mutation was already applied; it never claims rollback.
struct EffectControl<'a> {
cancel: &'a CancelHandle,
deadline: Instant,
#[cfg(test)]
test_control: TestEffectControl,
}
impl<'a> EffectControl<'a> {
fn new(
cancel: &'a CancelHandle,
timeout_ms: u64,
#[cfg(test)] test_control: TestEffectControl,
) -> Self {
Self {
cancel,
deadline: Instant::now() + Duration::from_millis(timeout_ms),
#[cfg(test)]
test_control,
}
}
fn checkpoint(&self, _checkpoint: EffectCheckpoint, mutation_applied: bool) -> Result<()> {
#[cfg(test)]
if self.test_control.cancel_at == Some(_checkpoint) {
self.cancel.cancel();
}
#[cfg(test)]
if self.test_control.timeout_at == Some(_checkpoint) {
return Err(Self::timeout_error(mutation_applied));
}
if self.cancel.is_cancelled() {
return Err(if mutation_applied {
tool_error(
smith::error::ToolCode::CancelledApplied,
"cancellation arrived after the file mutation was applied",
)
} else {
SmithError::Cancelled
});
}
if Instant::now() >= self.deadline {
return Err(Self::timeout_error(mutation_applied));
}
Ok(())
}
fn timeout_error(mutation_applied: bool) -> SmithError {
tool_error(
if mutation_applied {
smith::error::ToolCode::BashTimeoutApplied
} else {
smith::error::ToolCode::ToolTimeout
},
if mutation_applied {
"tool deadline elapsed after the file mutation was applied"
} else {
"tool deadline elapsed before the effect completed"
},
)
}
}
fn execute_effect(
name: &str,
input: &ToolArguments,
working_dir: &Path,
config: &Config,
cancel: &CancelHandle,
observed: &mut HashMap<PathBuf, FileVersion>,
#[cfg(test)] test_effect_control: TestEffectControl,
) -> std::result::Result<String, SmithError> {
let control = EffectControl::new(
cancel,
config.tool_timeout_ms,
#[cfg(test)]
test_effect_control,
);
control.checkpoint(EffectCheckpoint::Start, false)?;
let path_str = input_string(input, "path")?.unwrap_or_default();
match name {
"bash" => run_bash(input, working_dir, config.tool_timeout_ms, cancel),
"read" => read_effect(
input,
resolve_path(path_str, working_dir),
observed,
&control,
),
"write" => write_effect(
input,
resolve_path(path_str, working_dir),
observed,
&control,
),
"edit" => edit_effect(
input,
resolve_path(path_str, working_dir),
observed,
&control,
),
"ls" => {
let dir = directory_or(path_str, working_dir);
if !dir.is_dir() {
return Err(tool_error(
smith::error::ToolCode::LsError,
format!("{} is not a directory", dir.display()),
));
}
let names = walk_relative(
&dir,
Some(1),
config,
&control,
smith::error::ToolCode::LsError,
)?;
Ok(bounded_join(names, LIST_ENTRIES_MAX))
}
"find" => {
let pattern = input_string(input, "pattern")?.unwrap_or_default();
let root = directory_or(path_str, working_dir);
if !root.is_dir() {
return Err(tool_error(
smith::error::ToolCode::FindError,
format!("{} is not a directory", root.display()),
));
}
let names = walk_relative(
&root,
None,
config,
&control,
smith::error::ToolCode::FindError,
)?
.into_iter()
.filter(|name| pattern.is_empty() || name.contains(pattern))
.collect();
Ok(bounded_join(names, LIST_ENTRIES_MAX))
}
"grep" => {
let query = input_string(input, "query")?.unwrap_or_default();
let target = directory_or(path_str, working_dir);
if target.is_dir() {
grep_tree(&target, query, config, &control)
} else {
grep_bounded(&target, query, &control)
}
}
_ => Err(tool_error(
smith::error::ToolCode::ToolNotImplemented,
format!("tool '{name}' has no built-in effect"),
)),
}
}
/// Read a bounded window and remember the version that was shown.
fn read_effect(
input: &ToolArguments,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
let count = |key: &str| {
input_unsigned(input, key)
.map(|value| value.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)),
control,
)?;
remember(path, observed);
Ok(text)
}
/// Replace a file atomically and remember the version just written.
fn write_effect(
input: &ToolArguments,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
let content = input_string(input, "content")?.ok_or_else(|| {
tool_error(
smith::error::ToolCode::Validation,
"write requires string 'content'",
)
})?;
write_atomic(&path, content.as_bytes(), control)?;
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: &ToolArguments,
path: PathBuf,
observed: &mut HashMap<PathBuf, FileVersion>,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
let old = input_string(input, "old_string")?.unwrap_or_default();
let new = input_string(input, "new_string")?.unwrap_or_default();
if let Some(seen) = observed.get(&path)
&& FileVersion::of(&path) != Some(*seen)
{
return Err(tool_error(
smith::error::ToolCode::StaleInput,
format!(
"{} changed since it was last read; read it again before editing",
path.display()
),
));
}
let result = edit_exact(&path, old, new, control)?;
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, depth_max: Option<usize>, config: &Config) -> ignore::Walk {
let respect = config.respect_ignore_files;
let mut builder = ignore::WalkBuilder::new(root);
builder
.max_depth(depth_max)
.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,
depth_max: Option<usize>,
config: &Config,
control: &EffectControl<'_>,
code: smith::error::ToolCode,
) -> std::result::Result<Vec<String>, SmithError> {
let mut names = Vec::new();
for found in walker(root, depth_max, config) {
control.checkpoint(EffectCheckpoint::TraversalEntry, false)?;
let found = found.map_err(|e| tool_error(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() >= LIST_ENTRIES_MAX {
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,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
let mut matches: Vec<String> = Vec::new();
let mut total_bytes = 0usize;
for found in walker(root, None, config) {
control.checkpoint(EffectCheckpoint::TraversalEntry, false)?;
let found =
found.map_err(|e| tool_error(smith::error::ToolCode::GrepError, 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 {
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
line.clear();
if !matches!(
read_line_bounded(&mut reader, &mut line, GREP_LINE_BYTES_MAX),
Ok(true)
) {
break;
}
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
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() >= GREP_MATCHES_MAX
|| 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: &ToolArguments,
working_dir: &Path,
timeout_ms: u64,
cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
let command = input_string(input, "command")?.ok_or_else(|| {
tool_error(
smith::error::ToolCode::Validation,
"bash requires string 'command'",
)
})?;
let requested = input_unsigned(input, "timeout_ms")?.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(tool_error(
smith::error::ToolCode::CancelledUnknown,
"shell was cancelled after spawning; mutation state is unknown",
));
}
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_error(
smith::error::ToolCode::BashTimeout,
format!("timed out after {requested} ms: {output}"),
));
}
match result.exit_code {
Some(0) => Ok(output),
Some(code) => Err(tool_error(
smith::error::ToolCode::BashExit,
format!("exit {code}: {output}"),
)),
None => Err(tool_error(
smith::error::ToolCode::BashKilled,
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,
bytes_max: usize,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
let file = fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
let mut reader = BufReader::with_capacity(64 * 1024, file);
// Binary sniff on the first bytes.
let sniff = reader
.fill_buf()
.map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
if sniff[..sniff.len().min(8192)].contains(&0) {
return Err(tool_error(
smith::error::ToolCode::BinaryContent,
"file contains NUL bytes; binary content is unsupported",
));
}
let mut out = String::new();
let mut buffer: Vec<u8> = Vec::with_capacity(8 * 1024);
let mut line_no = 0usize;
let mut bytes_out = 0usize;
loop {
control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
buffer.clear();
#[expect(
clippy::disallowed_methods,
reason = "Take caps one read-tool line before truncation is reported"
)]
let bytes_read = (&mut reader)
.take((DEFAULT_OUTPUT_CAP + 1) as u64)
.read_until(b'\n', &mut buffer)
.map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
let line_ended = buffer.ends_with(b"\n")
|| reader
.fill_buf()
.map_err(io_tool_error(smith::error::ToolCode::ReadError))?
.is_empty();
if bytes_read == 0 || buffer.len() > DEFAULT_OUTPUT_CAP || !line_ended {
break;
}
control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
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 + buffer.len() > bytes_max {
break; // keep fitting prefix only
}
bytes_out += buffer.len();
out.push_str(&String::from_utf8_lossy(&buffer));
if bytes_out >= bytes_max {
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],
control: &EffectControl<'_>,
) -> std::result::Result<(), SmithError> {
control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
let parent_existed = parent.is_dir();
fs::create_dir_all(parent).map_err(io_tool_error(smith::error::ToolCode::WriteMkdir))?;
control.checkpoint(EffectCheckpoint::ParentCreated, !parent_existed)?;
control.checkpoint(EffectCheckpoint::WriteChunk, !parent_existed)?;
}
let temporary = temp_sibling(path);
let write = || -> std::result::Result<(), SmithError> {
let mut file = fs::File::create(&temporary)
.map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
for chunk in content.chunks(64 * 1024) {
control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
file.write_all(chunk)
.map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
}
control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
file.sync_all()
.map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
control.checkpoint(EffectCheckpoint::BeforePublish, false)
};
if let Err(error) = write() {
// Preserve the write failure; temporary-file removal is best-effort cleanup.
let _cleanup_result: std::io::Result<()> = fs::remove_file(&temporary);
return Err(error);
}
control.checkpoint(EffectCheckpoint::BeforePublish, false)?;
fs::rename(&temporary, path).map_err(|error| {
// Preserve the publish failure; temporary-file removal is best-effort cleanup.
let _cleanup_result: std::io::Result<()> = fs::remove_file(&temporary);
io_tool_error(smith::error::ToolCode::WritePublish)(error)
})?;
control.checkpoint(EffectCheckpoint::AfterPublish, true)
}
/// 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,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
let meta = fs::metadata(path).map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
if meta.len() > u64::try_from(EDIT_FILE_BYTES_MAX).unwrap_or(u64::MAX) {
return Err(tool_error(
smith::error::ToolCode::FileTooLarge,
"edit input exceeds the file size limit",
));
}
let bytes = read_edit_input(path, meta.len(), control)?;
let content = String::from_utf8(bytes).map_err(|_| {
tool_error(
smith::error::ToolCode::EditNotUtf8,
"edit supports UTF-8 files only",
)
})?;
let count = content.matches(old).count();
if count == 0 {
return Err(tool_error(
smith::error::ToolCode::EditNotFound,
"old_string not found; file unchanged",
));
}
if count > 1 {
return Err(tool_error(
smith::error::ToolCode::EditAmbiguous,
format!("old_string matches {count} locations; file unchanged"),
));
}
let updated = content.replacen(old, new, 1);
write_atomic(path, updated.as_bytes(), control)?;
Ok("edited".to_string())
}
/// Read edit input in bounded chunks so cancellation stays live between reads.
fn read_edit_input(
path: &Path,
expected_len: u64,
control: &EffectControl<'_>,
) -> std::result::Result<Vec<u8>, SmithError> {
control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
let capacity = usize::try_from(expected_len).unwrap_or(EDIT_FILE_BYTES_MAX);
let mut file =
fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
let mut bytes = Vec::with_capacity(capacity);
let mut chunk = [0_u8; 16 * 1024];
loop {
control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
let read = file
.read(&mut chunk)
.map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
if read == 0 {
break;
}
if bytes.len() + read > EDIT_FILE_BYTES_MAX {
return Err(tool_error(
smith::error::ToolCode::FileTooLarge,
"edit input exceeds the file size limit",
));
}
bytes.extend_from_slice(&chunk[..read]);
}
Ok(bytes)
}
/// 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,
control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
let file =
fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?;
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
let mut reader = BufReader::with_capacity(64 * 1024, file);
let mut matches: Vec<String> = Vec::new();
let mut total_bytes = 0usize;
loop {
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
let mut line = Vec::new();
#[expect(
clippy::disallowed_methods,
reason = "Take caps one grep line before overflow is reported"
)]
let bytes_read = (&mut reader)
.take((GREP_LINE_BYTES_MAX + 1) as u64)
.read_until(b'\n', &mut line)
.map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?;
if bytes_read == 0 {
break;
}
let line_ended = line.ends_with(b"\n")
|| reader
.fill_buf()
.map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?
.is_empty();
if line.len() > GREP_LINE_BYTES_MAX || !line_ended {
matches.push("<truncated>".to_string());
break;
}
if line.ends_with(b"\n") {
line.pop();
}
if line.last() == Some(&b'\r') {
line.pop();
}
control.checkpoint(EffectCheckpoint::GrepLine, false)?;
let line = String::from_utf8(line).map_err(|error| {
io_tool_error(smith::error::ToolCode::GrepReadError)(std::io::Error::new(
std::io::ErrorKind::InvalidData,
error,
))
})?;
if line.contains(query) {
if matches.len() >= GREP_MATCHES_MAX || 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::*;
macro_rules! test_args {
($($value:tt)*) => {
ToolArguments::from_serializable(&serde_json::json!($($value)*)).unwrap()
};
}
use crate::process_signal::{ProcessSignal, closed};
use crate::session::EntryContent;
use crate::session::EntryKind;
use std::path::PathBuf;
use std::sync::mpsc;
use std::thread;
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", &test_args!({"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", &test_args!({})).unwrap().output;
assert_eq!(listed, "keep.txt\nnested");
// grep searches the tree and locates each match.
let hits = ts
.invoke("grep", &test_args!({"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", &test_args!({"pattern": ".txt"}))
.unwrap()
.output;
assert_eq!(found, ".hidden.txt\nignored.txt\nkeep.txt\nnested/deep.txt");
let hits = permissive
.invoke("grep", &test_args!({"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", &test_args!({"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 error = ts
.invoke(
"edit",
&test_args!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
)
.unwrap_err();
assert_eq!(error.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", &test_args!({"path": "doc.txt"})).unwrap();
ts.invoke(
"edit",
&test_args!({"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",
&test_args!({"command": "printf hi > made.txt; printf done"}),
)
.unwrap();
assert!(outcome.ok);
assert_eq!(outcome.mutation_state, MutationState::Unknown);
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", "other"]);
assert_eq!(
ts.session.mutation_state(outcome.call_id).unwrap(),
MutationState::Unknown
);
}
#[test]
fn cancelled_shell_after_write_records_unknown_mutation_state() {
let dir = workdir("bash_cancelled_mutation");
let signal = ProcessSignal::new();
let command = format!("printf made > made.txt; {}; sleep 30", signal.raise());
let mut ts = ToolSession::new(&dir);
let cancel = ts.cancel_handle();
let (done, finished) = mpsc::channel();
thread::spawn(move || {
let error = ts
.invoke(
"bash",
&test_args!({"command": command, "timeout_ms": 30_000}),
)
.unwrap_err();
done.send((error, ts)).unwrap();
});
let mut socket = signal.wait();
cancel.cancel();
assert!(closed(&mut socket), "cancelled shell kept running");
let (error, ts) = finished.recv_timeout(Duration::from_secs(10)).unwrap();
assert_eq!(error.code(), "CANCELLED_UNKNOWN");
assert_eq!(
std::fs::read_to_string(dir.join("made.txt")).unwrap(),
"made"
);
let entry = ts.session.active_branch().entries().last().unwrap();
let EntryContent::Meta { detail, .. } = ts.session.content(entry).unwrap() else {
unreachable!()
};
let call_id =
serde_json::from_value(serde_json::to_value(&detail).unwrap()["call_id"].clone())
.unwrap();
assert_eq!(
ts.session.mutation_state(call_id).unwrap(),
MutationState::Unknown
);
}
#[test]
fn failing_and_timing_out_commands_report_explicitly() {
let dir = workdir("bash_failures");
let mut ts = ToolSession::new(&dir);
let error = ts
.invoke("bash", &test_args!({"command": "exit 3"}))
.unwrap_err();
assert_eq!(error.code(), "BASH_EXIT");
let error = ts
.invoke(
"bash",
&test_args!({"command": "sleep 5", "timeout_ms": 150}),
)
.unwrap_err();
assert_eq!(error.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 error = ts
.invoke("write", &test_args!({"path": "never.txt", "content": "x"}))
.unwrap_err();
assert_eq!(error.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",
&test_args!({"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 error = running.join().unwrap().unwrap_err();
assert_eq!(error.code(), "CANCELLED_UNKNOWN");
}
#[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.frame_bytes_max = 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: a shell may have
// mutated, but successful exit alone cannot prove it.
let error = ts
.invoke(
"bash",
&test_args!({"command": "printf made > actual.txt; printf 'x%.0s' $(seq 5000)"}),
)
.unwrap_err();
assert_eq!(error.code(), "RECORD_FAILED");
assert!(matches!(
error,
SmithError::RecordFailed {
mutation_state: MutationState::Unknown,
..
}
));
assert_eq!(
std::fs::read_to_string(dir.join("actual.txt")).unwrap(),
"made"
);
let later = ts
.invoke(
"write",
&test_args!({"path": "must-not-run.txt", "content": "x"}),
)
.unwrap_err();
assert!(matches!(
later,
SmithError::RecordFailed {
mutation_state: MutationState::Unknown,
..
}
));
assert!(!dir.join("must-not-run.txt").exists());
// 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 error = ts
.invoke("write", &test_args!({"path": "data.txt"}))
.unwrap_err();
assert_eq!(error.code(), "VALIDATION");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
let error = ts
.invoke("write", &test_args!({"path": "data.txt", "content": 42}))
.unwrap_err();
assert_eq!(error.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",
&test_args!({"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 error = ts
.invoke(
"edit",
&test_args!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
)
.unwrap_err();
assert_eq!(error.code(), "EDIT_AMBIGUOUS");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
let error = ts
.invoke(
"edit",
&test_args!({"path": "f.txt", "old_string": "NOT_PRESENT", "new_string": "b"}),
)
.unwrap_err();
assert_eq!(error.code(), "EDIT_NOT_FOUND");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
// Unambiguous edit succeeds.
ts.invoke(
"edit",
&test_args!({"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",
&test_args!({"path": "lines.txt", "offset": 2, "limit": 3}),
)
.unwrap();
assert_eq!(out.output, "line-2\nline-3\nline-4\n");
let capped = ts
.invoke("read", &test_args!({"path": "lines.txt", "max_bytes": 20}))
.unwrap();
assert!(capped.output.len() <= 20);
std::fs::write(dir.join("bin.dat"), [0x62, 0x00, 0x62]).unwrap();
let error = ts
.invoke("read", &test_args!({"path": "bin.dat"}))
.unwrap_err();
assert_eq!(error.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", &test_args!({})).unwrap();
assert_eq!(out.output, "alpha\nmid\nzeta");
let found = ts.invoke("find", &test_args!({"pattern": "a"})).unwrap();
assert_eq!(found.output, "alpha\nzeta");
let error = ts
.invoke("find", &test_args!({"path": "missing-dir", "pattern": "x"}))
.unwrap_err();
assert_eq!(error.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", &test_args!({"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 error = ts.invoke("rm", &test_args!({})).unwrap_err();
assert_eq!(error.code(), "TOOL_NOT_REGISTERED");
let error = ts.invoke("read", &test_args!("not-an-object")).unwrap_err();
assert_eq!(error.code(), "INVALID_INPUT");
let _ = std::fs::remove_dir_all(&dir);
}
fn assert_recorded_failure(ts: &ToolSession) {
let entries = ts.session.active_branch().entries();
assert!(matches!(
entries.iter().rev().nth(1).map(|entry| entry.kind),
Some(EntryKind::ToolResult { ok: false, .. })
));
assert!(matches!(
entries.last().map(|entry| entry.kind),
Some(EntryKind::Meta { .. })
));
}
fn cancel_at(
dir: &Path,
checkpoint: EffectCheckpoint,
name: &str,
input: serde_json::Value,
) -> (SmithError, ToolSession) {
let mut ts = ToolSession::new(dir);
ts.cancel_at_checkpoint(checkpoint);
let error = ts
.invoke(name, &ToolArguments::from_serializable(&input).unwrap())
.unwrap_err();
(error, ts)
}
fn timeout_at(
dir: &Path,
checkpoint: EffectCheckpoint,
name: &str,
input: serde_json::Value,
) -> (SmithError, ToolSession) {
let mut ts = ToolSession::new(dir);
ts.timeout_at_checkpoint(checkpoint);
let error = ts
.invoke(name, &ToolArguments::from_serializable(&input).unwrap())
.unwrap_err();
(error, ts)
}
#[test]
fn cancellation_stops_file_effects_at_cooperative_boundaries() {
let dir = workdir("file_cancellation");
std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();
for (checkpoint, name, input) in [
(
EffectCheckpoint::ReadChunk,
"read",
serde_json::json!({"path": "source.txt"}),
),
(
EffectCheckpoint::GrepLine,
"grep",
serde_json::json!({"path": "source.txt", "query": "needle"}),
),
(
EffectCheckpoint::EditReadChunk,
"edit",
serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
),
(
EffectCheckpoint::TraversalEntry,
"find",
serde_json::json!({"pattern": "child"}),
),
(
EffectCheckpoint::TraversalEntry,
"grep",
serde_json::json!({"path": "", "query": "needle"}),
),
] {
let (error, ts) = cancel_at(&dir, checkpoint, name, input);
assert_eq!(error.code(), "CANCELLED");
assert_recorded_failure(&ts);
}
assert_eq!(
std::fs::read_to_string(dir.join("source.txt")).unwrap(),
"needle\nold\n"
);
}
#[test]
fn configured_timeout_stops_file_effects_at_cooperative_boundaries() {
let dir = workdir("file_timeout_boundaries");
std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();
for (checkpoint, name, input) in [
(
EffectCheckpoint::ReadChunk,
"read",
serde_json::json!({"path": "source.txt"}),
),
(
EffectCheckpoint::GrepLine,
"grep",
serde_json::json!({"path": "source.txt", "query": "needle"}),
),
(
EffectCheckpoint::EditReadChunk,
"edit",
serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
),
(
EffectCheckpoint::WriteChunk,
"write",
serde_json::json!({"path": "new.txt", "content": "new"}),
),
(
EffectCheckpoint::TraversalEntry,
"ls",
serde_json::json!({}),
),
] {
let (error, ts) = timeout_at(&dir, checkpoint, name, input);
assert_eq!(error.code(), "TOOL_TIMEOUT");
assert_recorded_failure(&ts);
}
assert_eq!(
std::fs::read_to_string(dir.join("source.txt")).unwrap(),
"needle\nold\n"
);
}
#[test]
fn cancellation_after_parent_creation_records_applied_directory_mutation() {
let dir = workdir("write_parent_cancellation");
let (error, ts) = cancel_at(
&dir,
EffectCheckpoint::ParentCreated,
"write",
serde_json::json!({"path": "a/b/target.txt", "content": "new"}),
);
assert_eq!(error.code(), "CANCELLED_APPLIED");
assert!(dir.join("a/b").is_dir());
assert!(!dir.join("a/b/target.txt").exists());
assert!(matches!(
ts.session.content(ts.session.active_branch().entries().last().unwrap()).unwrap(),
EntryContent::Meta { kind, detail }
if kind == "tool-mutation"
&& serde_json::to_value(&detail).unwrap()["mutation_state"] == "applied"
));
}
#[test]
fn deadline_after_parent_creation_records_applied_directory_mutation() {
let dir = workdir("write_parent_deadline");
let (error, ts) = timeout_at(
&dir,
EffectCheckpoint::ParentCreated,
"write",
serde_json::json!({"path": "a/b/target.txt", "content": "new"}),
);
assert_eq!(error.code(), "TOOL_TIMEOUT_APPLIED");
assert!(dir.join("a/b").is_dir());
assert!(!dir.join("a/b/target.txt").exists());
let entry = ts.session.active_branch().entries().last().unwrap();
assert!(matches!(
ts.session.content(entry).unwrap(),
EntryContent::Meta { detail, .. } if serde_json::to_value(&detail).unwrap()["mutation_state"] == "applied"
));
}
#[test]
fn cancellation_before_or_after_atomic_publication_reports_truthfully() {
let dir = workdir("write_commit_cancellation");
let target = dir.join("target.txt");
std::fs::write(&target, "old").unwrap();
let (error, ts) = cancel_at(
&dir,
EffectCheckpoint::BeforePublish,
"write",
serde_json::json!({"path": "target.txt", "content": "new"}),
);
assert_eq!(error.code(), "CANCELLED");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "old");
assert_recorded_failure(&ts);
let (error, ts) = cancel_at(
&dir,
EffectCheckpoint::AfterPublish,
"write",
serde_json::json!({"path": "target.txt", "content": "new"}),
);
assert_eq!(error.code(), "CANCELLED_APPLIED");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
assert_recorded_failure(&ts);
}
#[test]
fn unrecordable_post_publication_cancellation_reports_applied_mutation() {
let dir = workdir("record_applied_cancellation");
let target = dir.join("target.txt");
std::fs::write(&target, "old").unwrap();
let mut writes = 0usize;
let mut ts = ToolSession::with_persist(&dir, Session::new(), move |_, _| {
writes += 1;
if writes == 1 {
Ok(())
} else {
Err(tool_error(
smith::error::ToolCode::TestRecord,
"result persistence rejected",
))
}
});
ts.cancel_at_checkpoint(EffectCheckpoint::AfterPublish);
let error = ts
.invoke(
"write",
&test_args!({"path": "target.txt", "content": "new"}),
)
.unwrap_err();
assert!(matches!(
error,
SmithError::RecordFailed {
mutation_state: MutationState::Applied,
..
}
));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn configured_deadline_stops_every_file_effect_before_it_starts() {
let dir = workdir("file_deadline");
std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();
let mut config = Config::default_valid();
// Inject an already elapsed effect budget; no wall-clock wait.
config.tool_timeout_ms = 0;
let mut ts = ToolSession::new(&dir).with_config(config);
for (name, input) in [
("read", serde_json::json!({"path": "source.txt"})),
(
"grep",
serde_json::json!({"path": "source.txt", "query": "needle"}),
),
("grep", serde_json::json!({"path": "", "query": "needle"})),
("ls", serde_json::json!({})),
("find", serde_json::json!({"pattern": "child"})),
(
"write",
serde_json::json!({"path": "source.txt", "content": "new"}),
),
(
"edit",
serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
),
] {
let error = ts
.invoke(name, &ToolArguments::from_serializable(&input).unwrap())
.unwrap_err();
assert_eq!(error.code(), "TOOL_TIMEOUT");
}
assert_eq!(
std::fs::read_to_string(dir.join("source.txt")).unwrap(),
"needle\nold\n"
);
assert_eq!(
ts.session
.active_branch()
.entries()
.iter()
.filter(|entry| matches!(entry.kind, EntryKind::ToolResult { ok: false, .. }))
.count(),
7
);
}
#[test]
fn failed_effects_are_recorded_as_failures() {
let dir = workdir("record_fail");
let mut ts = ToolSession::new(&dir);
let res = ts.invoke("read", &test_args!({"path": "nope.txt"}));
assert!(res.is_err());
// Call, failed result, and typed mutation completion are recorded.
assert_eq!(ts.session.active_branch().entries().len(), 3);
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);
}
}