//! 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, bytes_max: usize, ) -> std::io::Result { #[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, /// 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 = 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 { 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), } /// 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, /// 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 { 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) -> 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, 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, 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 { 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 { 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 { 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>) -> Result { 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 { encode_metadata(&ToolMutationMetadata::new(call_id, mutation_state)) } fn tool_error(code: smith::error::ToolCode, message: impl Into) -> 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> { 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> { 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) -> 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, timeout_at: Option, } /// 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, #[cfg(test)] test_effect_control: TestEffectControl, ) -> std::result::Result { 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, control: &EffectControl<'_>, ) -> std::result::Result { 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, control: &EffectControl<'_>, ) -> std::result::Result { 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, control: &EffectControl<'_>, ) -> std::result::Result { 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) { 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, 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, config: &Config, control: &EffectControl<'_>, code: smith::error::ToolCode, ) -> std::result::Result, 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 { let mut matches: Vec = 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("".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 { 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 { 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 = 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 { 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, 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, 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"); } out } /// Stream matching lines with bounded memory and result count. fn grep_bounded( path: &Path, query: &str, control: &EffectControl<'_>, ) -> std::result::Result { 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 = 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("".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("".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 = 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::>() }) .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); } }