nano-coder 0.23.0

A 6MB coding agent for the terminal and for agent fleets: multi-provider, ACP, resumable sessions, plans.
//! Model-facing output bounding.
//!
//! Ported from unreal-agent's `harness/operation/output.go`
//! (MIT, Copyright (c) 2026 Unreal Labs): keep the head and tail of long
//! output, mark how much was dropped, and point at the full capture.

/// Default model-facing output cap, in characters.
pub const DEFAULT_MAX_OUTPUT_LENGTH: usize = 40_000;
/// Largest `max_output_length` a model may request.
pub const MAX_OUTPUT_LENGTH: usize = 1_000_000;

/// Where complete copies of truncated tool output are kept.
pub fn spill_dir() -> std::path::PathBuf {
    std::env::temp_dir().join(format!("nano-coder-{}", std::process::id()))
}

/// Bound `text` to `limit` characters; when it is longer, save it whole in
/// `dir` and point at that file from the truncation marker, so the model can
/// search or page through the rest. The spill never overwrites an existing
/// file: for a persisted session the recorded paths must stay valid, so if
/// `name` is taken (a reused tool-call ID after a resume or provider retry) a
/// fresh `stem-2.ext`, `stem-3.ext`, … is reserved instead.
pub fn bound_and_spill(text: &str, limit: usize, dir: &std::path::Path, name: &str) -> String {
    if text.chars().count() <= limit {
        return text.to_string();
    }
    let saved = std::fs::create_dir_all(dir).ok().and_then(|()| reserve_and_write(dir, name, text));
    let display = saved.as_ref().map(|p| p.display().to_string());
    bound_parts(text, Some(text), text.len() as u64, limit, display.as_deref()).0
}

/// Write `text` to a new file in `dir` derived from `name`, never clobbering an
/// existing spill. Probes `name`, then `stem-2.ext`, `stem-3.ext`, … with
/// `create_new` until one is created, so paths already recorded in a session
/// log keep pointing at their original contents. Returns the path written, or
/// `None` on error.
fn reserve_and_write(dir: &std::path::Path, name: &str, text: &str) -> Option<std::path::PathBuf> {
    use std::io::Write;
    let (stem, ext) = match name.rsplit_once('.') {
        Some((stem, ext)) => (stem, format!(".{ext}")),
        None => (name, String::new()),
    };
    for attempt in 1..=10_000u32 {
        let candidate = if attempt == 1 {
            dir.join(name)
        } else {
            dir.join(format!("{stem}-{attempt}{ext}"))
        };
        match std::fs::File::create_new(&candidate) {
            Ok(mut file) => return file.write_all(text.as_bytes()).ok().map(|()| candidate),
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(_) => return None,
        }
    }
    None
}

/// Bound a complete in-memory string to `limit` characters.
pub fn bound_output(text: &str, limit: usize) -> (String, bool) {
    bound_parts(text, None, text.len() as u64, limit, None)
}

/// Bound output given its `head` and, when the full text was not read, its
/// `tail`. `full_size` is the complete size in bytes; `path` is where the
/// complete output lives, if anywhere.
pub fn bound_parts(head: &str, tail: Option<&str>, full_size: u64, limit: usize, path: Option<&str>) -> (String, bool) {
    let tail = match tail {
        Some(tail) => tail,
        None => {
            if head.chars().count() <= limit {
                return (head.to_string(), false);
            }
            head
        }
    };
    let head_part = take_head(head, limit / 2);
    let tail_part = take_tail(tail, limit - limit / 2);
    let skipped = full_size.saturating_sub((head_part.len() + tail_part.len()) as u64);
    let mut marker = format!("...{skipped} bytes truncated");
    if let Some(path) = path {
        marker.push_str("; complete output in ");
        marker.push_str(path);
    }
    (format!("{head_part}{marker}...{tail_part}"), true)
}

fn take_head(text: &str, chars: usize) -> &str {
    match text.char_indices().nth(chars) {
        Some((end, _)) => &text[..end],
        None => text,
    }
}

fn take_tail(text: &str, chars: usize) -> &str {
    if chars == 0 {
        return "";
    }
    match text.char_indices().rev().nth(chars - 1) {
        Some((start, _)) => &text[start..],
        None => text,
    }
}

/// Parse an optional model-supplied `max_output_length`.
pub fn parse_max_output_length(value: Option<&serde_json::Value>) -> Result<usize, String> {
    let Some(value) = value.filter(|v| !v.is_null()) else {
        return Ok(DEFAULT_MAX_OUTPUT_LENGTH);
    };
    let limit = value
        .as_i64()
        .ok_or_else(|| "max_output_length must be a positive integer".to_string())?;
    if limit <= 0 {
        return Err("max_output_length must be a positive integer".into());
    }
    if limit as usize > MAX_OUTPUT_LENGTH {
        return Err(format!("max_output_length must not exceed {MAX_OUTPUT_LENGTH}"));
    }
    Ok(limit as usize)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn leaves_short_output_alone() {
        assert_eq!(bound_output("hello", 5), ("hello".to_string(), false));
    }

    #[test]
    fn keeps_head_and_tail() {
        let (bounded, truncated) = bound_output("abcdefghij", 4);
        assert!(truncated);
        assert_eq!(bounded, "ab...6 bytes truncated...ij");
    }

    #[test]
    fn is_utf8_safe_and_mentions_path() {
        let text = "é".repeat(10);
        let (bounded, truncated) = bound_parts(&text, Some(&text), 1000, 3, Some("/tmp/out"));
        assert!(truncated);
        assert_eq!(bounded, "é...994 bytes truncated; complete output in /tmp/out...éé");
    }

    #[test]
    fn spills_long_output_to_a_file() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(bound_and_spill("short", 10, dir.path(), "a.txt"), "short");
        assert!(!dir.path().join("a.txt").exists());
        let bounded = bound_and_spill("abcdefghij", 4, dir.path(), "b.txt");
        let path = dir.path().join("b.txt");
        assert_eq!(bounded, format!("ab...6 bytes truncated; complete output in {}...ij", path.display()));
        assert_eq!(std::fs::read_to_string(path).unwrap(), "abcdefghij");
    }

    #[test]
    fn spill_never_overwrites_an_existing_file() {
        let dir = tempfile::tempdir().unwrap();
        // First long result claims the base name.
        let first = bound_and_spill("abcdefghij", 4, dir.path(), "tool-x.txt");
        assert!(first.contains("tool-x.txt"));
        assert_eq!(std::fs::read_to_string(dir.path().join("tool-x.txt")).unwrap(), "abcdefghij");
        // A second result reusing the same name (e.g. a retried tool-call ID)
        // must not clobber the first: it reserves a fresh suffixed name.
        let second = bound_and_spill("klmnopqrst", 4, dir.path(), "tool-x.txt");
        assert!(second.contains("tool-x-2.txt"));
        assert_eq!(std::fs::read_to_string(dir.path().join("tool-x.txt")).unwrap(), "abcdefghij");
        assert_eq!(std::fs::read_to_string(dir.path().join("tool-x-2.txt")).unwrap(), "klmnopqrst");
    }

    #[test]
    fn validates_limits() {
        assert_eq!(parse_max_output_length(None), Ok(DEFAULT_MAX_OUTPUT_LENGTH));
        assert_eq!(parse_max_output_length(Some(&json!(10))), Ok(10));
        assert!(parse_max_output_length(Some(&json!(0))).is_err());
        assert!(parse_max_output_length(Some(&json!("5"))).is_err());
        assert!(parse_max_output_length(Some(&json!(MAX_OUTPUT_LENGTH + 1))).is_err());
    }
}