1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! `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());
}
}