litany 0.0.12

A git-backed agent harness
Documentation
//! `read_file` built-in (ARCH §3.3, §12 v0.3 toolset).
//!
//! Stdin is the `tool_use.input` block as JSON:
//! `{ "path": <string>, "offset": <line>?, "limit": <lines>? }`.
//! Stdout is the selected lines' raw bytes; exit code 0 on success.
//! Errors land on stderr (the executor concats it after stdout into
//! `tool_result.content` per §3.3) and the process exits non-zero.
//!
//! **The read says what it returned** (bl-cbe0,
//! `docs/DESIGN_CODE_EXECUTION.md` §3). Every invocation writes one
//! [`range::Selection::note`] line to stderr — the lines returned, the
//! file's total, and, when lines remain, the `offset` to continue at.
//! Stdout stays the file's bytes verbatim, because a model that reads a
//! file and then patches it must not have a header line of ours in what
//! it believes the file says; the envelope surfaces stderr on success
//! too, so the note reaches the model either way. Without it a read cut
//! by the `tool_output:` bound (2 KiB + 2 KiB since bl-ce09) tells the
//! model *that* the middle is missing but not which lines to ask for
//! next, and a `sed -n 'A,Bp'` re-read has to guess A and B.
//!
//! Oversized files are rejected with [`Error::TooLarge`] rather than
//! truncated — the cap is on the FILE, not on the selection, so
//! `offset`/`limit` narrow a read that fits and do not open one that
//! does not (`docs/DESIGN_CONTEXT_ECONOMY.md` §7's second gap, still
//! open). The auto-dispatch shim that turns oversized output into a
//! summarized read is deferred to v0.4+ (epic non-goal in §11/§12).

mod range;

use serde::Deserialize;
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use thiserror::Error;

/// Hard ceiling on bytes the tool will hand back to the model. 1 MiB
/// is comfortably above any single source file the agent would read
/// directly and small enough that the result stays tractable in the
/// model's context window. Tools that need larger reads should
/// reach for `bash` (e.g. `head -n N`) until the v0.4 auto-dispatch
/// path lands.
pub const MAX_BYTES: u64 = 1024 * 1024;

/// Wire shape of the input. `serde` enforces required-and-no-extras so
/// a malformed `tool_use.input` surfaces as [`Error::InvalidJson`]
/// rather than a silent fallback.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
    path: PathBuf,
    /// 1-based line to begin at. Omitted reads from line 1 — the
    /// general path with the parameter absent, not a second mode.
    offset: Option<u64>,
    /// How many lines to return. Omitted reads to the end of the file.
    limit: Option<u64>,
}

/// Where a read begins when the model named no `offset`.
const FIRST_LINE: u64 = 1;

/// Every way [`run`] can fail. Each variant produces a distinct
/// stderr message — the operator running `litany tool read_file`
/// directly sees these on the terminal, and the model sees them
/// concatenated into `tool_result.content` when the executor builds
/// the next step's request (§3.3).
#[derive(Debug, Error)]
pub enum Error {
    /// Stdin handed back bytes that did not parse as the documented
    /// shape — wrong type, missing `path`, or extra fields.
    #[error("invalid input JSON: {0}")]
    InvalidJson(#[source] serde_json::Error),
    /// The harness's stdin pipe failed mid-read. Distinct from
    /// [`Error::InvalidJson`] so a transient pipe failure isn't
    /// misattributed to the model.
    #[error("read input from stdin: {0}")]
    StdinRead(#[source] io::Error),
    /// `stat` or `open` failed — file missing, permission denied,
    /// path is a directory, etc. The path is captured so the message
    /// pinpoints which input was bad.
    #[error("open {path}: {source}", path = path.display())]
    Open {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    /// The file's size exceeds [`MAX_BYTES`]. Reported as a hard
    /// rejection rather than a truncation so the agent sees the cap
    /// and chooses a different tactic. `size` is the file's **true**
    /// size (`stat` on the open fd), not the capped read's length —
    /// an agent deciding between `head -c` and a different tactic
    /// needs the real magnitude.
    #[error(
        "file {path} is {size} bytes (cap {cap}); use a streaming tool",
        path = path.display()
    )]
    TooLarge { path: PathBuf, cap: u64, size: u64 },
    /// `read` returned an I/O error after `open` succeeded — disk
    /// fault, mid-read truncation, etc.
    #[error("read {path}: {source}", path = path.display())]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    /// `offset` or `limit` was zero. Both count from one — there is no
    /// line 0 and no zero-line read — so a zero is a mistake with a
    /// plausible intent (`offset: 0` meaning the first line), and
    /// answering it silently would return a range the model did not
    /// ask for.
    #[error("{field} must be 1 or more (lines count from 1); got 0")]
    Range { field: &'static str },
    /// Writing to stdout failed. Only fires when the harness's stdout
    /// pipe is closed before we finish writing — a fault, not a tool
    /// failure delivered to the model.
    #[error("write to stdout: {0}")]
    Write(#[source] io::Error),
}

/// Read the input from `stdin`, open the named file, and write its
/// bytes to `stdout`. Pure over [`Read`]/[`Write`] so unit tests drive
/// it with `Cursor`/`Vec`; the `litany tool read_file` shim wires it
/// to the live process stdio.
pub fn run<R: Read, W: Write, E: Write>(
    stdin: &mut R,
    stdout: &mut W,
    stderr: &mut E,
) -> Result<(), Error> {
    let mut buf = Vec::new();
    stdin.read_to_end(&mut buf).map_err(Error::StdinRead)?;
    let input: Input = serde_json::from_slice(&buf).map_err(Error::InvalidJson)?;
    let offset = input.offset.unwrap_or(FIRST_LINE);
    if offset == 0 {
        return Err(Error::Range { field: "offset" });
    }
    if input.limit == Some(0) {
        return Err(Error::Range { field: "limit" });
    }
    let path = input.path;

    let file = File::open(&path).map_err(|source| Error::Open {
        path: path.clone(),
        source,
    })?;
    // `take(MAX_BYTES + 1)` enforces the cap on the happy path with no
    // `metadata` call: a file at the cap reads MAX_BYTES bytes and
    // succeeds; anything larger trips the post-read length check
    // below, which then (and only then) stats for the true size.
    let mut content = Vec::new();
    let mut capped = file.take(MAX_BYTES + 1);
    capped
        .read_to_end(&mut content)
        .map_err(|source| Error::Read {
            path: path.clone(),
            source,
        })?;
    let read = content.len() as u64;
    if read > MAX_BYTES {
        // The capped read only proves the file is over the cap — its
        // length is `cap + 1` by construction, so reporting it would
        // fabricate the same size for every oversize file. `stat` the
        // already-open fd for the true size, floored at what we read:
        // a stream whose metadata understates its content (procfs
        // reports len 0) still reports at least the bytes seen.
        let size = capped
            .get_ref()
            .metadata()
            .map_or(read, |m| m.len().max(read));
        let cap = MAX_BYTES;
        return Err(Error::TooLarge { path, cap, size });
    }
    let selected = range::select(&content, offset, input.limit);
    stdout
        .write_all(
            content
                .get(selected.start..selected.end)
                .unwrap_or_default(),
        )
        .map_err(Error::Write)?;
    stderr
        .write_all(selected.note().as_bytes())
        .map_err(Error::Write)
}

#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_range;