Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-core/src/tools.rs

Raw
//! Tool registry, validation, built-in file effects, and the durable
//! outcome boundary between successful effects and recorded session frames.
//!
//! (`SMH-SPEC-SPEC0001`, Tools).

use crate::session::{Entry, EntryContentRef, Session};
use crate::store::SessionWriter;
use smith::config::Config;
use smith::error::{Result, SmithError};
use smith::id::ToolCallId;
use smith::tool::{CancelHandle, ToolMetadata};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

const HARD_OUTPUT_CAP: usize = 256 * 1024;
const DEFAULT_OUTPUT_CAP: usize = 64 * 1024;
const MAX_LIST_ENTRIES: usize = 1000;
const MAX_GREP_MATCHES: usize = 200;
const MAX_EDIT_FILE_BYTES: u64 = 8 * 1024 * 1024;

/// Registry of available tools.
#[derive(Clone, Debug, Default)]
pub struct ToolRegistry {
    /// Tools by name.
    tools: HashMap<String, ToolMetadata>,
    /// Name-ordered snapshot handed to every provider request; rebuilt on
    /// registration, shared afterwards.
    snapshot: Arc<[ToolMetadata]>,
}

impl ToolRegistry {
    /// An empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Register or replace a tool definition.
    pub fn register(&mut self, meta: ToolMetadata) {
        self.tools.insert(meta.name.clone(), meta);
        let mut ordered: Vec<ToolMetadata> = self.tools.values().cloned().collect();
        ordered.sort_by(|left, right| left.name.cmp(&right.name));
        self.snapshot = ordered.into();
    }

    /// The registered tool, when present.
    #[must_use]
    pub fn get(&self, name: &str) -> Option<&ToolMetadata> {
        self.tools.get(name)
    }

    /// The name-ordered tool definitions, for provider requests.
    #[must_use]
    pub fn snapshot(&self) -> Arc<[ToolMetadata]> {
        Arc::clone(&self.snapshot)
    }

    /// A registry with the seven built-in coding tools and real schemas.
    #[must_use]
    pub fn with_builtins() -> Self {
        let mut reg = Self::new();
        for meta in builtin_tools() {
            reg.register(meta);
        }
        reg
    }
}

/// Built-in tool definitions with schemas used for argument validation.
#[must_use]
pub fn builtin_tools() -> Vec<ToolMetadata> {
    let object = |required: &[&str], props: serde_json::Value| {
        serde_json::json!({
            "type": "object",
            "required": required,
            "properties": props,
        })
    };
    vec![
        ToolMetadata::new("read", "Read a bounded line window from a file.").with_schema(object(
            &["path"],
            serde_json::json!({
                "path": {"type": "string"},
                "offset": {"type": "integer"},
                "limit": {"type": "integer"},
                "max_bytes": {"type": "integer"},
            }),
        )),
        ToolMetadata::new("write", "Atomically replace a file, creating parents.").with_schema(
            object(
                &["path", "content"],
                serde_json::json!({
                    "path": {"type": "string"},
                    "content": {"type": "string"},
                }),
            ),
        ),
        ToolMetadata::new("edit", "Apply one exact, unambiguous replacement.").with_schema(object(
            &["path", "old_string", "new_string"],
            serde_json::json!({
                "path": {"type": "string"},
                "old_string": {"type": "string"},
                "new_string": {"type": "string"},
            }),
        )),
        ToolMetadata::new("ls", "List a directory deterministically.")
            .with_schema(object(&[], serde_json::json!({"path": {"type": "string"}}))),
        ToolMetadata::new("find", "Find directory entries matching a substring.").with_schema(
            object(
                &["pattern"],
                serde_json::json!({
                    "path": {"type": "string"},
                    "pattern": {"type": "string"},
                }),
            ),
        ),
        ToolMetadata::new("grep", "Return bounded matching lines from a file.").with_schema(
            object(
                &["path", "query"],
                serde_json::json!({
                    "path": {"type": "string"},
                    "query": {"type": "string"},
                }),
            ),
        ),
        ToolMetadata::new("bash", "Run a bounded cancellable shell command.").with_schema(object(
            &["command"],
            serde_json::json!({
                "command": {"type": "string"},
                "timeout_ms": {"type": "integer"},
            }),
        )),
    ]
}

/// Bundles the registry, the live session, and the durable writer so a
/// successful mutation can never be reported while its recording failed.
pub struct ToolSession {
    registry: ToolRegistry,
    /// Live session state; entries are appended as they are recorded.
    pub session: Session,
    writer: Option<SessionWriter>,
    /// All file effects resolve relative to this directory.
    pub working_dir: PathBuf,
    config: Config,
    cancel: CancelHandle,
    /// What `read` and `write` last observed per file, used to reject edits
    /// whose input describes an older version of the file.
    observed: HashMap<PathBuf, FileVersion>,
}

/// Cheap identity of a file version: size plus modification time.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct FileVersion {
    len: u64,
    modified_ms: i64,
}

impl FileVersion {
    fn of(path: &Path) -> Option<Self> {
        let meta = fs::metadata(path).ok()?;
        let modified_ms = meta
            .modified()
            .ok()?
            .duration_since(std::time::UNIX_EPOCH)
            .ok()?
            .as_millis();
        Some(Self {
            len: meta.len(),
            modified_ms: i64::try_from(modified_ms).unwrap_or(i64::MAX),
        })
    }
}

impl ToolSession {
    /// A tool session over built-in tools, no persistence.
    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
        Self {
            registry: ToolRegistry::with_builtins(),
            session: Session::new(),
            writer: None,
            working_dir: working_dir.into(),
            config: Config::default_valid(),
            cancel: CancelHandle::new(),
            observed: HashMap::new(),
        }
    }

    /// Use `config` for effect timeouts and frame limits.
    #[must_use]
    pub fn with_config(mut self, config: Config) -> Self {
        self.config = config;
        self
    }

    /// The registered tool definitions, for provider requests.
    #[must_use]
    pub fn registered_tools(&self) -> Arc<[ToolMetadata]> {
        self.registry.snapshot()
    }

    /// A handle that aborts in-flight and subsequent effects of this session.
    #[must_use]
    pub fn cancel_handle(&self) -> CancelHandle {
        self.cancel.clone()
    }

    pub(crate) fn set_cancel_handle(&mut self, cancel: CancelHandle) {
        self.cancel = cancel;
    }

    /// Continue an existing session, appending every recorded entry to its
    /// durable writer.
    pub fn with_session(
        working_dir: impl Into<PathBuf>,
        session: Session,
        writer: SessionWriter,
    ) -> Self {
        Self {
            session,
            writer: Some(writer),
            ..Self::new(working_dir)
        }
    }

    /// Register or replace a tool definition.
    pub fn register(&mut self, meta: ToolMetadata) {
        self.registry.register(meta);
    }

    /// Validate arguments against the registered schema (required fields and
    /// declared property types) before any effect can run.
    ///
    /// # Errors
    ///
    /// Returns [`SmithError::Tool`] for unknown tools, non-object input,
    /// missing required fields, or mismatched field types.
    pub fn validate(&self, name: &str, input: &serde_json::Value) -> Result<()> {
        let meta = self.registry.get(name).ok_or_else(|| {
            tool_err(
                "TOOL_NOT_REGISTERED",
                format!("tool '{name}' not registered"),
            )
        })?;
        let obj = input
            .as_object()
            .ok_or_else(|| tool_err("INVALID_INPUT", "input must be a JSON object"))?;
        let schema = &meta.input_schema;
        for req in schema
            .get("required")
            .and_then(|v| v.as_array())
            .into_iter()
            .flatten()
        {
            let key = req.as_str().unwrap_or_default();
            if !obj.contains_key(key) {
                return Err(tool_err(
                    "VALIDATION",
                    format!("missing required field '{key}' for tool '{name}'"),
                ));
            }
        }
        if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
            for (key, value) in obj {
                if let Some(expected) = props
                    .get(key)
                    .and_then(|p| p.get("type"))
                    .and_then(|t| t.as_str())
                    && !json_type_matches(expected, value)
                {
                    {
                        return Err(tool_err(
                            "VALIDATION",
                            format!("field '{key}' for tool '{name}' must be of type {expected}"),
                        ));
                    }
                }
            }
        }
        Ok(())
    }

    /// Validate, execute, and durably record one tool call.
    ///
    /// Recording order is call entry, effect, result entry, so a recorded
    /// call always precedes the effect it describes. A cancelled session
    /// refuses to start an effect. If the effect runs but its result cannot
    /// be recorded, the error states whether host state was already mutated,
    /// instead of presenting an unrecorded success.
    ///
    /// # Errors
    ///
    /// Returns [`SmithError::Cancelled`] when the session was cancelled,
    /// [`SmithError::RecordFailed`] when a completed effect could not be
    /// recorded, or the effect's own error.
    pub fn invoke(&mut self, name: &str, input: &serde_json::Value) -> Result<ToolOutcome> {
        self.invoke_recorded(
            ToolCallId::new(),
            name,
            input,
            input,
            str::to_string,
            EffectFailure::ReturnError,
        )
    }

    /// Execute trusted local arguments while recording only their redacted
    /// representation.
    ///
    /// `recorded_input` crosses persistence and provider boundaries;
    /// `effect_input` exists only for the duration of the authorized local
    /// effect. `redact_output` is applied before either the outcome or its
    /// durable result can escape that boundary. Executed tool failures become
    /// recorded outcomes so the model can observe and recover from them.
    pub(crate) fn invoke_local(
        &mut self,
        call_id: ToolCallId,
        name: &str,
        recorded_input: &serde_json::Value,
        effect_input: &serde_json::Value,
        redact_output: impl Fn(&str) -> String,
    ) -> Result<ToolOutcome> {
        self.invoke_recorded(
            call_id,
            name,
            recorded_input,
            effect_input,
            redact_output,
            EffectFailure::ReturnOutcome,
        )
    }

    fn invoke_recorded(
        &mut self,
        call_id: ToolCallId,
        name: &str,
        recorded_input: &serde_json::Value,
        effect_input: &serde_json::Value,
        redact_output: impl Fn(&str) -> String,
        failure: EffectFailure,
    ) -> Result<ToolOutcome> {
        self.validate(name, recorded_input)?;
        if self.cancel.is_cancelled() && failure == EffectFailure::ReturnError {
            return Err(SmithError::Cancelled);
        }
        self.record(EntryContentRef::ToolCall {
            name,
            input: recorded_input,
            call_id,
        })?;
        let effect = if self.cancel.is_cancelled() {
            Err(SmithError::Cancelled)
        } else {
            execute_effect(
                name,
                effect_input,
                &self.working_dir,
                &self.config,
                &self.cancel,
                &mut self.observed,
            )
        };
        let ok = effect.is_ok();
        let output = match &effect {
            Ok(text) => redact_output(text),
            Err(err) => redact_output(&err.to_string()),
        };
        self.record(EntryContentRef::ToolResult {
            ok,
            output: &output,
            call_id,
        })
        .map_err(|err| SmithError::RecordFailed {
            tool: name.to_string(),
            mutation_applied: ok && mutates_host(name),
            message: err.to_string(),
        })?;
        let outcome = ToolOutcome {
            call_id,
            name: name.to_string(),
            ok,
            output,
            timestamp_ms: now_ms(),
        };
        match effect {
            Ok(_) => Ok(outcome),
            Err(_) if failure == EffectFailure::ReturnOutcome => Ok(outcome),
            Err(error) => Err(error),
        }
    }

    /// Append `content` after the selected entry and durably record it
    /// first; a failed write leaves the session unchanged.
    pub(crate) fn record<'a>(&mut self, content: impl Into<EntryContentRef<'a>>) -> Result<Entry> {
        let writer = self.writer.as_mut();
        self.session
            .append_with(content, |entry, bytes| {
                writer.map_or(Ok(()), |writer| writer.append_entry(entry, bytes))
            })
            .copied()
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum EffectFailure {
    ReturnError,
    ReturnOutcome,
}

/// A recorded tool outcome returned to the caller.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ToolOutcome {
    /// Tool call ID.
    pub call_id: ToolCallId,
    /// Tool name.
    pub name: String,
    /// Whether the effect succeeded.
    pub ok: bool,
    /// Recorded output or error message.
    pub output: String,
    /// Timestamp of outcome.
    pub timestamp_ms: i64,
}

fn tool_err(code: &str, message: impl Into<String>) -> SmithError {
    SmithError::Tool {
        code: code.to_string(),
        message: message.into(),
    }
}

fn io_tool_err(code: &'static str) -> impl Fn(std::io::Error) -> SmithError {
    move |e| tool_err(code, e.to_string())
}

fn json_type_matches(expected: &str, value: &serde_json::Value) -> bool {
    match expected {
        "string" => value.is_string(),
        "number" => value.is_number(),
        "integer" => value.is_i64() || value.is_u64(),
        "boolean" => value.is_boolean(),
        "array" => value.is_array(),
        "object" => value.is_object(),
        "null" => value.is_null(),
        _ => true,
    }
}

fn resolve_path(input_path: &str, working_dir: &Path) -> PathBuf {
    let p = Path::new(input_path);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        working_dir.join(p)
    }
}

/// Execute a built-in file effect. All outputs are bounded and deterministic;
/// filesystem errors are surfaced, never converted to empty success.
/// Tools whose successful effect changes host state.
fn mutates_host(name: &str) -> bool {
    matches!(name, "write" | "edit" | "bash")
}

fn execute_effect(
    name: &str,
    input: &serde_json::Value,
    working_dir: &Path,
    config: &Config,
    cancel: &CancelHandle,
    observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
    let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
    match name {
        "bash" => run_bash(input, working_dir, config.tool_timeout_ms, cancel),
        "read" => read_effect(input, resolve_path(path_str, working_dir), observed),
        "write" => write_effect(input, resolve_path(path_str, working_dir), observed),
        "edit" => edit_effect(input, resolve_path(path_str, working_dir), observed),
        "ls" => {
            let dir = directory_or(path_str, working_dir);
            if !dir.is_dir() {
                return Err(tool_err(
                    "LS_ERROR",
                    format!("{} is not a directory", dir.display()),
                ));
            }
            let names = walk_relative(&dir, Some(1), config, cancel, "LS_ERROR")?;
            Ok(bounded_join(names, MAX_LIST_ENTRIES))
        }
        "find" => {
            let pattern = input
                .get("pattern")
                .and_then(|v| v.as_str())
                .unwrap_or_default();
            let root = directory_or(path_str, working_dir);
            if !root.is_dir() {
                return Err(tool_err(
                    "FIND_ERROR",
                    format!("{} is not a directory", root.display()),
                ));
            }
            let names = walk_relative(&root, None, config, cancel, "FIND_ERROR")?
                .into_iter()
                .filter(|name| pattern.is_empty() || name.contains(pattern))
                .collect();
            Ok(bounded_join(names, MAX_LIST_ENTRIES))
        }
        "grep" => {
            let query = input
                .get("query")
                .and_then(|v| v.as_str())
                .unwrap_or_default();
            let target = directory_or(path_str, working_dir);
            if target.is_dir() {
                grep_tree(&target, query, config, cancel)
            } else {
                grep_bounded(&target, query)
            }
        }
        _ => Err(tool_err(
            "TOOL_NOT_IMPLEMENTED",
            format!("tool '{name}' has no built-in effect"),
        )),
    }
}

/// Read a bounded window and remember the version that was shown.
fn read_effect(
    input: &serde_json::Value,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
    let count = |key: &str| {
        input
            .get(key)
            .and_then(serde_json::Value::as_u64)
            .map(|value| usize::try_from(value).unwrap_or(usize::MAX))
    };
    let text = read_bounded(
        &path,
        count("offset").unwrap_or(0),
        count("limit").unwrap_or(usize::MAX),
        count("max_bytes").map_or(DEFAULT_OUTPUT_CAP, |bytes| bytes.min(HARD_OUTPUT_CAP)),
    )?;
    remember(path, observed);
    Ok(text)
}

/// Replace a file atomically and remember the version just written.
fn write_effect(
    input: &serde_json::Value,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
    let content = input
        .get("content")
        .and_then(|value| value.as_str())
        .ok_or_else(|| tool_err("VALIDATION", "write requires string 'content'"))?;
    write_atomic(&path, content.as_bytes())?;
    remember(path, observed);
    Ok(format!("written {} bytes", content.len()))
}

/// Apply an exact replacement, refusing input that describes an older
/// version of the file.
///
/// An edit describes a file the caller has seen. If the file moved on since
/// then, applying the replacement would silently discard the newer content.
fn edit_effect(
    input: &serde_json::Value,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
) -> std::result::Result<String, SmithError> {
    let text = |key: &str| input.get(key).and_then(|v| v.as_str()).unwrap_or_default();
    if let Some(seen) = observed.get(&path)
        && FileVersion::of(&path) != Some(*seen)
    {
        return Err(tool_err(
            "STALE_INPUT",
            format!(
                "{} changed since it was last read; read it again before editing",
                path.display()
            ),
        ));
    }
    let result = edit_exact(&path, text("old_string"), text("new_string"))?;
    remember(path, observed);
    Ok(result)
}

/// Record the current version of `path` as the one the caller has seen.
fn remember(path: PathBuf, observed: &mut HashMap<PathBuf, FileVersion>) {
    if let Some(version) = FileVersion::of(&path) {
        observed.insert(path, version);
    }
}

/// The requested path, or the invocation directory when none was given.
fn directory_or(path_str: &str, working_dir: &Path) -> PathBuf {
    if path_str.is_empty() {
        working_dir.to_path_buf()
    } else {
        resolve_path(path_str, working_dir)
    }
}

/// Ignore-aware, deterministic walk of `root`.
///
/// Traversal order is sorted by path, and configured ignore files and hidden
/// entries are honored, so identical trees always produce identical results.
fn walker(root: &Path, max_depth: Option<usize>, config: &Config) -> ignore::Walk {
    let respect = config.respect_ignore_files;
    let mut builder = ignore::WalkBuilder::new(root);
    builder
        .max_depth(max_depth)
        .hidden(!config.include_hidden)
        .ignore(respect)
        .git_ignore(respect)
        .git_global(respect)
        .git_exclude(respect)
        .parents(respect)
        // Honor ignore files even outside a checkout, so listing behavior
        // does not depend on whether a VCS happens to be present.
        .require_git(false)
        .sort_by_file_path(std::cmp::Ord::cmp);
    builder.build()
}

/// Paths under `root`, relative to it, bounded and cancellable.
fn walk_relative(
    root: &Path,
    max_depth: Option<usize>,
    config: &Config,
    cancel: &CancelHandle,
    code: &'static str,
) -> std::result::Result<Vec<String>, SmithError> {
    let mut names = Vec::new();
    for found in walker(root, max_depth, config) {
        if cancel.is_cancelled() {
            return Err(SmithError::Cancelled);
        }
        let found = found.map_err(|e| tool_err(code, e.to_string()))?;
        if found.depth() == 0 {
            continue;
        }
        names.push(
            found
                .path()
                .strip_prefix(root)
                .unwrap_or_else(|_| found.path())
                .to_string_lossy()
                .into_owned(),
        );
        if names.len() >= MAX_LIST_ENTRIES {
            break;
        }
    }
    Ok(names)
}

/// Search every non-ignored file under `root` for `query`.
///
/// Matches are reported as `relative/path:line:text`, bounded by match count
/// and total output, and unreadable or binary files are skipped rather than
/// failing the whole search.
fn grep_tree(
    root: &Path,
    query: &str,
    config: &Config,
    cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
    let mut matches: Vec<String> = Vec::new();
    let mut total_bytes = 0usize;
    for found in walker(root, None, config) {
        if cancel.is_cancelled() {
            return Err(SmithError::Cancelled);
        }
        let found = found.map_err(|e| tool_err("GREP_ERROR", e.to_string()))?;
        if !found.file_type().is_some_and(|kind| kind.is_file()) {
            continue;
        }
        let relative = found
            .path()
            .strip_prefix(root)
            .unwrap_or_else(|_| found.path())
            .to_string_lossy()
            .into_owned();
        let Ok(file) = fs::File::open(found.path()) else {
            continue;
        };
        let mut reader = BufReader::with_capacity(64 * 1024, file);
        let mut line = Vec::new();
        let mut line_no = 0usize;
        loop {
            line.clear();
            match reader.read_until(b'\n', &mut line) {
                Ok(0) | Err(_) => break,
                Ok(_) => {}
            }
            if line.contains(&0) {
                break; // binary content
            }
            line_no += 1;
            let text = String::from_utf8_lossy(&line).trim_end().to_string();
            if !text.contains(query) {
                continue;
            }
            let rendered = format!("{relative}:{line_no}:{text}");
            if matches.len() >= MAX_GREP_MATCHES
                || total_bytes + rendered.len() > DEFAULT_OUTPUT_CAP
            {
                matches.push("<truncated>".to_string());
                return Ok(matches.join("\n"));
            }
            total_bytes += rendered.len();
            matches.push(rendered);
        }
    }
    Ok(matches.join("\n"))
}

/// Run one bounded, cancellable shell command in the invocation directory.
fn run_bash(
    input: &serde_json::Value,
    working_dir: &Path,
    timeout_ms: u64,
    cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
    let command = input
        .get("command")
        .and_then(|v| v.as_str())
        .ok_or_else(|| tool_err("VALIDATION", "bash requires string 'command'"))?;
    let requested = input
        .get("timeout_ms")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(timeout_ms);
    let result = crate::bash::execute(
        command,
        working_dir.to_str(),
        requested.min(timeout_ms),
        DEFAULT_OUTPUT_CAP,
        cancel,
    )?;
    if result.cancelled {
        return Err(SmithError::Cancelled);
    }
    let mut output = result.stdout;
    if !result.stderr.is_empty() {
        if !output.is_empty() {
            output.push('\n');
        }
        output.push_str("[stderr] ");
        output.push_str(&result.stderr);
    }
    if result.timed_out {
        return Err(tool_err(
            "BASH_TIMEOUT",
            format!("timed out after {requested} ms: {output}"),
        ));
    }
    match result.exit_code {
        Some(0) => Ok(output),
        Some(code) => Err(tool_err("BASH_EXIT", format!("exit {code}: {output}"))),
        None => Err(tool_err(
            "BASH_KILLED",
            format!("terminated without exit status: {output}"),
        )),
    }
}

/// Read a bounded line window without materializing the whole file.
fn read_bounded(
    path: &Path,
    offset: usize,
    limit: usize,
    max_bytes: usize,
) -> std::result::Result<String, SmithError> {
    let file = fs::File::open(path).map_err(io_tool_err("READ_ERROR"))?;
    let mut reader = BufReader::with_capacity(64 * 1024, file);
    // Binary sniff on the first bytes.
    let sniff = reader.fill_buf().map_err(io_tool_err("READ_ERROR"))?;
    if sniff[..sniff.len().min(8192)].contains(&0) {
        return Err(tool_err(
            "BINARY_CONTENT",
            "file contains NUL bytes; binary content is unsupported",
        ));
    }
    let mut out = String::new();
    let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
    let mut line_no = 0usize;
    let mut bytes_out = 0usize;
    loop {
        buf.clear();
        match reader.read_until(b'\n', &mut buf) {
            Ok(0) => break,
            Ok(_) => {}
            Err(e) => return Err(io_tool_err("READ_ERROR")(e)),
        }
        line_no += 1;
        if line_no <= offset {
            continue;
        }
        let emit = if limit == usize::MAX {
            true
        } else {
            (line_no - offset) <= limit
        };
        if !emit {
            break;
        }
        if bytes_out + buf.len() > max_bytes {
            break; // keep fitting prefix only
        }
        bytes_out += buf.len();
        out.push_str(&String::from_utf8_lossy(&buf));
        if bytes_out >= max_bytes {
            break;
        }
    }
    Ok(out)
}

/// Atomically replace `path` with `content`: temp file in the same
/// directory, synced, then renamed over the destination.
fn write_atomic(path: &Path, content: &[u8]) -> std::result::Result<(), SmithError> {
    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
    if let Some(parent) = parent {
        fs::create_dir_all(parent).map_err(io_tool_err("WRITE_MKDIR"))?;
    }
    let tmp = temp_sibling(path);
    let write = |tmp: &Path| -> std::io::Result<()> {
        let mut f = fs::File::create(tmp)?;
        f.write_all(content)?;
        f.sync_all()?;
        Ok(())
    };
    write(&tmp).map_err(io_tool_err("WRITE_ERROR"))?;
    fs::rename(&tmp, path).map_err(|e| {
        let _ = fs::remove_file(&tmp);
        io_tool_err("WRITE_PUBLISH")(e)
    })?;
    Ok(())
}

/// A hidden sibling path used for atomic publication.
fn temp_sibling(path: &Path) -> PathBuf {
    static COUNTER: AtomicUsize = AtomicUsize::new(0);
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let name = path.file_name().map_or_else(
        || "file".to_string(),
        |name| name.to_string_lossy().into_owned(),
    );
    path.with_file_name(format!(".{name}.tmp-{}-{n}", std::process::id()))
}

/// Apply exactly one unambiguous replacement, publishing atomically.
fn edit_exact(path: &Path, old: &str, new: &str) -> std::result::Result<String, SmithError> {
    let meta = fs::metadata(path).map_err(io_tool_err("EDIT_READ_ERROR"))?;
    if meta.len() > MAX_EDIT_FILE_BYTES {
        return Err(tool_err(
            "FILE_TOO_LARGE",
            "edit input exceeds the file size limit",
        ));
    }
    let bytes = fs::read(path).map_err(io_tool_err("EDIT_READ_ERROR"))?;
    let content = String::from_utf8(bytes)
        .map_err(|_| tool_err("EDIT_NOT_UTF8", "edit supports UTF-8 files only"))?;
    let count = content.matches(old).count();
    if count == 0 {
        return Err(tool_err(
            "EDIT_NOT_FOUND",
            "old_string not found; file unchanged",
        ));
    }
    if count > 1 {
        return Err(tool_err(
            "EDIT_AMBIGUOUS",
            format!("old_string matches {count} locations; file unchanged"),
        ));
    }
    let updated = content.replacen(old, new, 1);
    write_atomic(path, updated.as_bytes())?;
    Ok("edited".to_string())
}

/// Deterministic, bounded listing output.
fn bounded_join(mut names: Vec<String>, cap: usize) -> String {
    names.truncate(cap);
    let mut out = names.join("\n");
    let max = DEFAULT_OUTPUT_CAP;
    if out.len() > max {
        out.truncate(max);
        out.push_str("\n<truncated>");
    }
    out
}

/// Stream matching lines with bounded memory and result count.
fn grep_bounded(path: &Path, query: &str) -> std::result::Result<String, SmithError> {
    let file = fs::File::open(path).map_err(io_tool_err("GREP_READ_ERROR"))?;
    let reader = BufReader::with_capacity(64 * 1024, file);
    let mut matches: Vec<String> = Vec::new();
    let mut total_bytes = 0usize;
    for line in reader.lines() {
        let line = line.map_err(io_tool_err("GREP_READ_ERROR"))?;
        if line.contains(query) {
            if matches.len() >= MAX_GREP_MATCHES || total_bytes + line.len() > DEFAULT_OUTPUT_CAP {
                matches.push("<truncated>".to_string());
                break;
            }
            total_bytes += line.len();
            matches.push(line);
        }
    }
    Ok(matches.join("\n"))
}

/// Wall-clock milliseconds since the Unix epoch, saturating on overflow.
fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |elapsed| {
            i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
        })
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    clippy::panic,
    reason = "tests may panic on invariant violations"
)]
mod tests {
    use super::*;
    use crate::session::EntryContent;
    use crate::session::EntryKind;
    use std::path::PathBuf;

    fn workdir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("smith_tools_{}_{}", tag, std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn listing_and_searching_respect_ignore_rules_and_hidden_entries() {
        let dir = workdir("ignore_rules");
        std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
        std::fs::write(dir.join("keep.txt"), "needle here\n").unwrap();
        std::fs::write(dir.join("ignored.txt"), "needle here\n").unwrap();
        std::fs::write(dir.join(".hidden.txt"), "needle here\n").unwrap();
        std::fs::create_dir_all(dir.join("nested")).unwrap();
        std::fs::write(dir.join("nested/deep.txt"), "needle deep\n").unwrap();

        let mut ts = ToolSession::new(&dir);
        // find walks recursively and drops ignored and hidden paths.
        let found = ts
            .invoke("find", &serde_json::json!({"pattern": ""}))
            .unwrap()
            .output;
        assert_eq!(found, "keep.txt\nnested\nnested/deep.txt");
        // ls stays one level deep, with the same filtering.
        let listed = ts.invoke("ls", &serde_json::json!({})).unwrap().output;
        assert_eq!(listed, "keep.txt\nnested");
        // grep searches the tree and locates each match.
        let hits = ts
            .invoke("grep", &serde_json::json!({"path": "", "query": "needle"}))
            .unwrap()
            .output;
        assert_eq!(
            hits,
            "keep.txt:1:needle here\nnested/deep.txt:1:needle deep"
        );

        // Configuration decides: without ignore rules, everything shows up.
        let mut config = Config::default_valid();
        config.respect_ignore_files = false;
        config.include_hidden = true;
        let mut permissive = ToolSession::new(&dir).with_config(config);
        let found = permissive
            .invoke("find", &serde_json::json!({"pattern": ".txt"}))
            .unwrap()
            .output;
        assert_eq!(found, ".hidden.txt\nignored.txt\nkeep.txt\nnested/deep.txt");
        let hits = permissive
            .invoke(
                "grep",
                &serde_json::json!({"path": "", "query": "needle here"}),
            )
            .unwrap()
            .output;
        assert_eq!(
            hits,
            ".hidden.txt:1:needle here\nignored.txt:1:needle here\nkeep.txt:1:needle here"
        );
    }

    #[test]
    fn edit_rejects_input_that_describes_an_older_version_of_the_file() {
        let dir = workdir("stale_edit");
        let target = dir.join("doc.txt");
        std::fs::write(&target, "alpha one\n").unwrap();
        let mut ts = ToolSession::new(&dir);
        ts.invoke("read", &serde_json::json!({"path": "doc.txt"}))
            .unwrap();

        // Somebody else rewrites the file after the read; same length, so only the mtime tells.
        std::fs::write(&target, "alpha two\n").unwrap();
        // inject: pin the rewrite's mtime past the read instead of sleeping for a clock tick.
        let later = std::fs::metadata(&target).unwrap().modified().unwrap()
            + std::time::Duration::from_secs(60);
        std::fs::File::options()
            .write(true)
            .open(&target)
            .unwrap()
            .set_modified(later)
            .unwrap();

        let err = ts
            .invoke(
                "edit",
                &serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "STALE_INPUT");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "alpha two\n");

        // Re-reading refreshes the observed version and unblocks the edit.
        ts.invoke("read", &serde_json::json!({"path": "doc.txt"}))
            .unwrap();
        ts.invoke(
            "edit",
            &serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
        )
        .unwrap();
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "beta two\n");
    }

    #[test]
    fn bash_effect_runs_in_the_workdir_and_is_recorded() {
        let dir = workdir("bash_effect");
        let mut ts = ToolSession::new(&dir);
        let outcome = ts
            .invoke(
                "bash",
                &serde_json::json!({"command": "printf hi > made.txt; printf done"}),
            )
            .unwrap();
        assert!(outcome.ok);
        assert_eq!(outcome.output, "done");
        assert_eq!(std::fs::read_to_string(dir.join("made.txt")).unwrap(), "hi");
        // A recorded call always precedes its result.
        let kinds: Vec<&str> = ts
            .session
            .active_branch()
            .entries()
            .iter()
            .map(|e| match e.kind {
                EntryKind::ToolCall { .. } => "call",
                EntryKind::ToolResult { .. } => "result",
                _ => "other",
            })
            .collect();
        assert_eq!(kinds, vec!["call", "result"]);
    }

    #[test]
    fn failing_and_timing_out_commands_report_explicitly() {
        let dir = workdir("bash_failures");
        let mut ts = ToolSession::new(&dir);
        let err = ts
            .invoke("bash", &serde_json::json!({"command": "exit 3"}))
            .unwrap_err();
        assert_eq!(err.code(), "BASH_EXIT");
        let err = ts
            .invoke(
                "bash",
                &serde_json::json!({"command": "sleep 5", "timeout_ms": 150}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "BASH_TIMEOUT");
        // Both failures stay durable evidence, recorded as failed results.
        let failed = ts
            .session
            .active_branch()
            .entries()
            .iter()
            .filter(|e| matches!(e.kind, EntryKind::ToolResult { ok: false, .. }))
            .count();
        assert_eq!(failed, 2);
    }

    #[test]
    fn cancellation_stops_effects_before_they_start() {
        let dir = workdir("cancel_effects");
        let mut ts = ToolSession::new(&dir);
        let cancel = ts.cancel_handle();
        cancel.cancel();
        let err = ts
            .invoke(
                "write",
                &serde_json::json!({"path": "never.txt", "content": "x"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "CANCELLED");
        assert!(!dir.join("never.txt").exists());
        // A refused call records nothing.
        assert!(ts.session.active_branch().entries().is_empty());
    }

    #[test]
    fn cancelling_a_running_command_aborts_it() {
        let dir = workdir("cancel_running");
        let mut ts = ToolSession::new(&dir);
        let cancel = ts.cancel_handle();
        let signal = crate::process_signal::ProcessSignal::new();
        let command = format!("{}; sleep 30", signal.raise());
        let running = std::thread::spawn(move || {
            ts.invoke(
                "bash",
                &serde_json::json!({"command": command, "timeout_ms": 30_000}),
            )
        });
        // synchronize: cancel once the command reports it runs, never after a guessed delay.
        let mut socket = signal.wait();
        cancel.cancel();
        // Before the join: a survivor would hold the pipes and stall it.
        assert!(
            crate::process_signal::closed(&mut socket),
            "command kept running"
        );
        let err = running.join().unwrap().unwrap_err();
        assert_eq!(err.code(), "CANCELLED");
    }

    #[test]
    fn unrecordable_result_after_a_mutation_reports_the_mutation() {
        let dir = workdir("record_failed");
        let session_path = dir.join("session.smh");
        let mut cfg = Config::default_valid();
        cfg.max_frame_bytes = 1024;
        let opened = crate::store::open_session(&session_path, cfg.clone()).unwrap();
        let mut ts =
            ToolSession::with_session(&dir, opened.session, opened.writer).with_config(cfg);
        // The call entry fits, the 5 KiB result does not: the effect already
        // ran, so the error has to admit the mutation.
        let err = ts
            .invoke(
                "bash",
                &serde_json::json!({"command": "printf 'x%.0s' $(seq 5000)"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "RECORD_FAILED");
        assert!(matches!(
            err,
            SmithError::RecordFailed {
                mutation_applied: true,
                ..
            }
        ));
        // Only the durably recorded call is part of the live session.
        let kinds: Vec<EntryKind> = ts
            .session
            .active_branch()
            .entries()
            .iter()
            .map(|e| e.kind)
            .collect();
        assert!(matches!(kinds.as_slice(), [EntryKind::ToolCall { .. }]));
    }

    #[test]
    fn write_requires_string_content_and_leaves_existing_file_untouched() {
        let dir = workdir("write_schema");
        let target = dir.join("data.txt");
        std::fs::write(&target, "original").unwrap();
        let mut ts = ToolSession::new(&dir);
        let err = ts
            .invoke("write", &serde_json::json!({"path": "data.txt"}))
            .unwrap_err();
        assert_eq!(err.code(), "VALIDATION");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
        let err = ts
            .invoke(
                "write",
                &serde_json::json!({"path": "data.txt", "content": 42}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "VALIDATION");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn write_creates_missing_parents_and_leaves_no_temp_files() {
        let dir = workdir("write_parents");
        let mut ts = ToolSession::new(&dir);
        ts.invoke(
            "write",
            &serde_json::json!({"path": "a/b/c.txt", "content": "deep"}),
        )
        .unwrap();
        assert_eq!(
            std::fs::read_to_string(dir.join("a/b/c.txt")).unwrap(),
            "deep"
        );
        let leftovers: Vec<_> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(std::result::Result::ok)
            .flat_map(|e| {
                std::fs::read_dir(e.path())
                    .unwrap()
                    .filter_map(std::result::Result::ok)
                    .map(|x| x.file_name().to_string_lossy().into_owned())
                    .collect::<Vec<_>>()
            })
            .filter(|n| n.contains(".tmp-"))
            .collect();
        assert!(
            leftovers.is_empty(),
            "temp files left behind: {leftovers:?}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn edit_rejects_ambiguous_and_missing_matches_without_changes() {
        let dir = workdir("edit_amb");
        let target = dir.join("f.txt");
        std::fs::write(&target, "a a").unwrap();
        let mut ts = ToolSession::new(&dir);
        let err = ts
            .invoke(
                "edit",
                &serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "EDIT_AMBIGUOUS");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
        let err = ts
            .invoke(
                "edit",
                &serde_json::json!({"path": "f.txt", "old_string": "NOT_PRESENT", "new_string": "b"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "EDIT_NOT_FOUND");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
        // Unambiguous edit succeeds.
        ts.invoke(
            "edit",
            &serde_json::json!({"path": "f.txt", "old_string": "a a", "new_string": "b"}),
        )
        .unwrap();
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "b");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn read_supports_line_windows_byte_caps_and_binary_detection() {
        let dir = workdir("read_caps");
        let mut body = String::new();
        for i in 0..100 {
            use std::fmt::Write as _;
            let _ = writeln!(body, "line-{i}");
        }
        std::fs::write(dir.join("lines.txt"), &body).unwrap();
        let mut ts = ToolSession::new(&dir);
        let out = ts
            .invoke(
                "read",
                &serde_json::json!({"path": "lines.txt", "offset": 2, "limit": 3}),
            )
            .unwrap();
        assert_eq!(out.output, "line-2\nline-3\nline-4\n");
        let capped = ts
            .invoke(
                "read",
                &serde_json::json!({"path": "lines.txt", "max_bytes": 20}),
            )
            .unwrap();
        assert!(capped.output.len() <= 20);
        std::fs::write(dir.join("bin.dat"), [0x62, 0x00, 0x62]).unwrap();
        let err = ts
            .invoke("read", &serde_json::json!({"path": "bin.dat"}))
            .unwrap_err();
        assert_eq!(err.code(), "BINARY_CONTENT");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn ls_and_find_are_sorted_and_surface_errors() {
        let dir = workdir("ls_find");
        for name in ["zeta", "alpha", "mid"] {
            std::fs::write(dir.join(name), "").unwrap();
        }
        let mut ts = ToolSession::new(&dir);
        let out = ts.invoke("ls", &serde_json::json!({})).unwrap();
        assert_eq!(out.output, "alpha\nmid\nzeta");
        let found = ts
            .invoke("find", &serde_json::json!({"pattern": "a"}))
            .unwrap();
        assert_eq!(found.output, "alpha\nzeta");
        let err = ts
            .invoke(
                "find",
                &serde_json::json!({"path": "missing-dir", "pattern": "x"}),
            )
            .unwrap_err();
        assert_eq!(err.code(), "FIND_ERROR");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn grep_returns_bounded_matches() {
        let dir = workdir("grep");
        std::fs::write(
            dir.join("log.txt"),
            "hit one\nmiss\nhit two\nmiss\nhit three\n",
        )
        .unwrap();
        let mut ts = ToolSession::new(&dir);
        let out = ts
            .invoke(
                "grep",
                &serde_json::json!({"path": "log.txt", "query": "hit"}),
            )
            .unwrap();
        assert_eq!(out.output, "hit one\nhit two\nhit three");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn unregistered_tools_and_non_object_input_are_rejected() {
        let dir = workdir("registry");
        let mut ts = ToolSession::new(&dir);
        let err = ts.invoke("rm", &serde_json::json!({})).unwrap_err();
        assert_eq!(err.code(), "TOOL_NOT_REGISTERED");
        let err = ts
            .invoke("read", &serde_json::json!("not-an-object"))
            .unwrap_err();
        assert_eq!(err.code(), "INVALID_INPUT");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn failed_effects_are_recorded_as_failures() {
        let dir = workdir("record_fail");
        let mut ts = ToolSession::new(&dir);
        let res = ts.invoke("read", &serde_json::json!({"path": "nope.txt"}));
        assert!(res.is_err());
        // Two entries: call + failed result, both recorded.
        assert_eq!(ts.session.active_branch().entries().len(), 2);
        let last = &ts.session.active_branch().entries()[1];
        match ts.session.content(last).unwrap() {
            EntryContent::ToolResult { ok, output, .. } => {
                assert!(!ok);
                assert!(output.contains("READ_ERROR"));
            }
            other => panic!("unexpected {other:?}"),
        }
        let _ = std::fs::remove_dir_all(&dir);
    }
}