newt-agent 0.7.5

Newt-Agent — free, friendly, local agentic coder (vi to Hermes's emacs)
//! `newt solve` — the headless, non-interactive entry that drives the agentic
//! loop to solve one task and emits a trace, for Terminal-Bench (epic #1419 /
//! the release-champion ceremony, WS1).
//!
//! It is a THIN wrapper over the same [`TurnDriver`] / `chat_complete` loop the
//! interactive TUI runs — no second loop. Headless contract:
//! `permission_gate: None` (a capability denial fails the call, never hangs) and
//! caveats default to [`Caveats::top`] (unconfined). `--non-interactive` sets
//! `NEWT_FULL_ACCESS=1` so the host shell is used and no prompt can appear — the
//! `--yolo --full-access` bootstrap lane. (The flight-recorder-derived-Caveats
//! confined lane is the later OCAP arc.)

use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;

use anyhow::{Context, Result};
use newt_core::{BackendKind, Config, TurnDriver, TurnDriverConfig, TurnStatus};

/// Parsed `newt solve` arguments (mirrors the `Command::Solve` fields).
pub struct SolveArgs {
    pub cwd: PathBuf,
    pub instruction_file: PathBuf,
    pub profile: Option<PathBuf>,
    pub non_interactive: bool,
    pub events: Option<PathBuf>,
    pub max_rounds: Option<usize>,
    /// The served model's FULL context window (e.g. llama.cpp `--ctx-size`).
    /// newt reserves ~20% for the reply and gates input at 80% of it, so a
    /// long turn compacts under the window instead of overrunning it during
    /// generation (the "Context size has been exceeded" 500s). None keeps
    /// newt's default.
    pub context_window: Option<usize>,
}

/// Run one task headless and emit its trace. Returns the process exit code:
/// `0` when the turn completed, `1` on an infrastructure/turn failure. (Task
/// pass/fail is Terminal-Bench's job via the task's own verification — this exit
/// code is only "did the agent run cleanly".)
pub async fn run(args: SolveArgs) -> Result<i32> {
    // 1. Config: an explicit --profile is a FILE (Config::load); else the normal
    //    search order (Config::resolve — honors disk drop-ins + --backend-*).
    let cfg = match &args.profile {
        Some(path) => {
            Config::load(path).with_context(|| format!("loading --profile {}", path.display()))?
        }
        None => Config::resolve().context("resolving config")?,
    };

    // 2. Non-interactive ⇒ the `--yolo --full-access` bootstrap lane: full
    //    access (Caveats::top) AND OCAP disabled, so an UNRESTRICTED fs write
    //    auto-accepts instead of waiting on a (nonexistent) prompt gate and
    //    silently denying — the write path the benchmark depends on. SAFETY:
    //    single-threaded before the driver spawns its turn thread.
    if args.non_interactive {
        unsafe {
            std::env::set_var("NEWT_FULL_ACCESS", "1");
            std::env::set_var("NEWT_DISABLE_OCAP", "1");
        }
    }

    // 3. Backend: honor NEWT_PROVIDER, else the first backend with an endpoint.
    let backend = pick_backend(&cfg)
        .context("no usable backend in config (set one in the --profile [[backends]] or via --backend-endpoint)")?;
    let url = backend.endpoint.clone();
    let model = backend
        .effective_model()
        .context("backend has no model (set model = in the [[backends]] entry)")?
        .to_string();
    let kind = backend.kind.unwrap_or(BackendKind::Openai);
    let api_key = backend.resolve_api_key();

    // #tenacity: attribute the model's family so a per-family `[tenacity]` config
    // default applies to this run (an explicit `--tenacity` still supersedes it).
    // The card's `family` if a built-in card names one, else a family inferred
    // from the model NAME against the configured `[tenacity.families]` keys — so
    // the model matrix (qwen3/gemma/nemotron/…) works from config without a card
    // per model.
    let card_family = newt_core::model_card::builtin_card(&model).and_then(|c| c.family);
    let family = cfg
        .tenacity
        .as_ref()
        .and_then(|t| t.family_for(&model, card_family.as_deref()))
        .or(card_family);
    newt_core::tenacity::set_active_model_family(family);

    // 4. The task instruction.
    let instruction = std::fs::read_to_string(&args.instruction_file).with_context(|| {
        format!(
            "reading --instruction-file {}",
            args.instruction_file.display()
        )
    })?;
    let workspace = args
        .cwd
        .canonicalize()
        .unwrap_or_else(|_| args.cwd.clone())
        .to_string_lossy()
        .into_owned();

    // 5. Drive one full turn (== a complete multi-round agentic solve).
    let mut dc = TurnDriverConfig::new(&url, &model, kind, &workspace);
    dc.api_key = api_key;
    if let Some(r) = args.max_rounds {
        dc.max_tool_rounds = r;
    }
    // Pin the model's served context window so the loop's pre-send guard +
    // compaction keep each request under the backend's `--ctx-size` (e.g. dgx1
    // llama.cpp serves qwen3-coder at 32768). `--context-window` is the FULL
    // served window; the input budget is 80% of it, RESERVING ~20% for the
    // reply — the server's KV window is shared by input+output, so gating on the
    // full window (no headroom) overruns it during generation and 500s (that was
    // the leak). This matches the workspace convention that `safe_context` is
    // the 80%-discounted window (mirrors the Ollama input-ceiling path). num_ctx
    // is inert on the OpenAI wire but kept for the Ollama path.
    if let Some(cw) = args.context_window {
        let cw = cw as u32;
        let input_budget = (u64::from(cw) * 80 / 100) as u32;
        dc.safe_context = Some(input_budget);
        dc.max_ok_input = Some(input_budget);
        dc.num_ctx = Some(cw);
    }
    let mut driver = TurnDriver::new(dc);
    let started = Instant::now();
    driver
        .submit(instruction.trim())
        .map_err(|e| anyhow::anyhow!("submit failed: {e:?}"))?;

    let outcome = loop {
        match driver.poll() {
            TurnStatus::Completed(o) => break Ok(o),
            TurnStatus::Failed(e) => break Err(e),
            TurnStatus::Idle | TurnStatus::Running => {
                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            }
        }
    };
    let wall_secs = started.elapsed().as_secs_f64();

    // 6. Emit the trace record (one JSONL line), including the per-tool-call
    //    trajectory (name/args-digest/ok/duration) the TurnDriver now lends —
    //    the material for the failure taxonomy.
    // A part-way inference failure now arrives as `Ok(o)` with `o.error` set —
    // so its PARTIAL trajectory is preserved (an infra failure must not report
    // the agent as having done nothing). `Err` is only a spawn/thread failure
    // with no trajectory at all.
    let o_opt = outcome.as_ref().ok();
    let (status, error) = match &outcome {
        Ok(o) if o.error.is_none() => ("completed", None),
        Ok(o) => ("failed", o.error.clone()),
        Err(e) => ("failed", Some(e.clone())),
    };
    let reply_chars = o_opt.map(|o| o.reply.len()).unwrap_or(0);
    let usage = o_opt.and_then(|o| o.usage.as_ref().map(|u| u.total()));
    let halluc = o_opt.map(|o| o.hallucinations).unwrap_or(0);
    // The per-tool trajectory — the material for the failure taxonomy. The
    // single highest-signal field is `write_calls`: a failed task with 0 writes
    // never ACTED (the tenacity target); with writes it acted but wrong. Only
    // newt's real workspace-write tools count — `write_file`/`edit_file` (the
    // `is_workspace_write_call` set); aliases like `create_file`/`str_replace`/
    // `apply_patch` get a coaching reply and never modify the tree.
    let (tool_calls, write_calls, end_reason, trajectory) = match o_opt {
        Some(o) => {
            let names: Vec<&str> = o.tool_events.iter().map(|e| e.tool.as_str()).collect();
            let writes = names
                .iter()
                .filter(|n| matches!(**n, "write_file" | "edit_file"))
                .count();
            (
                names.len(),
                writes,
                format!("{:?}", o.end_reason),
                serde_json::to_value(&o.tool_events).unwrap_or(serde_json::Value::Null),
            )
        }
        None => (0, 0, "None".to_string(), serde_json::Value::Null),
    };
    let record = serde_json::json!({
        "kind": "solve_result",
        "task_file": args.instruction_file.to_string_lossy(),
        "cwd": workspace,
        "model": model,
        "endpoint": url,
        "backend_kind": kind.label(),
        "status": status,
        "reply_chars": reply_chars,
        "usage_total_tokens": usage,
        "hallucinations": halluc,
        "wall_secs": wall_secs,
        "tool_calls": tool_calls,
        "write_calls": write_calls,
        "end_reason": end_reason,
        "trajectory": trajectory,
        "error": error,
    });
    if let Some(path) = &args.events {
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)
            .with_context(|| format!("opening --events {}", path.display()))?;
        writeln!(f, "{record}").context("writing events line")?;
    }
    // Always echo the record to stdout too, so a manual bootstrap run is legible.
    println!("{record}");

    // Clean completion → 0; any failure (inference error carried on the outcome,
    // or a spawn/thread Err) → 1.
    let clean = matches!(&outcome, Ok(o) if o.error.is_none());
    Ok(if clean { 0 } else { 1 })
}

/// Pick the backend to drive: `NEWT_PROVIDER` by name if set and present, else
/// the first configured backend that has an endpoint.
fn pick_backend(cfg: &Config) -> Option<&newt_core::config::BackendConfig> {
    if let Ok(name) = std::env::var("NEWT_PROVIDER") {
        if let Some(b) = cfg.backends.iter().find(|b| b.name == name) {
            return Some(b);
        }
    }
    cfg.backends.iter().find(|b| !b.endpoint.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;
    use newt_core::config::BackendConfig;

    fn backend(name: &str, endpoint: &str) -> BackendConfig {
        BackendConfig {
            name: name.into(),
            endpoint: endpoint.into(),
            ..Default::default()
        }
    }

    #[test]
    fn pick_backend_skips_endpointless_and_takes_the_first_usable() {
        // Deterministic: no NEWT_PROVIDER selection path.
        // SAFETY: single-threaded test; restore is not needed (we only remove).
        unsafe { std::env::remove_var("NEWT_PROVIDER") };
        let cfg = Config {
            backends: vec![
                backend("embedded-no-endpoint", ""),
                backend("dgx", "http://router:8080"),
                backend("other", "http://other:9000"),
            ],
            ..Default::default()
        };
        let chosen = pick_backend(&cfg).expect("a usable backend exists");
        assert_eq!(
            chosen.name, "dgx",
            "skip the endpointless one, take the first usable"
        );
    }

    #[test]
    fn pick_backend_none_when_no_endpoints() {
        unsafe { std::env::remove_var("NEWT_PROVIDER") };
        let cfg = Config {
            backends: vec![backend("a", ""), backend("b", "")],
            ..Default::default()
        };
        assert!(pick_backend(&cfg).is_none());
    }
}