harness_loop/lib.rs
1//! ReAct agent loop with self-correction.
2//!
3//! Minimal v0.0.1 implementation:
4//! - Applies guides once at the start.
5//! - Sends `Context` (with `tools`) to the model.
6//! - Dispatches each returned tool call via [`ToolRegistry`].
7//! - Runs `Sensor::SelfCorrect` sensors after each action; auto-fix patches are
8//! applied directly to the world, blocking signals are fed back to the model.
9//! - Stops when the model returns no tool calls, or when `policy.max_iters` is hit.
10
11pub mod acceptance;
12pub mod learning;
13pub mod memory_layer;
14#[cfg(feature = "otel")]
15pub mod otel;
16pub mod profile_guide;
17pub mod recall_layer;
18pub mod registry;
19pub use acceptance::{Acceptance, FilesExist, NonEmptyAnswer, Verdict};
20pub mod replay;
21pub mod subagent;
22pub mod telemetry;
23
24pub use learning::*;
25pub use memory_layer::*;
26pub mod boundary_guide;
27pub use boundary_guide::*;
28pub use profile_guide::*;
29pub use recall_layer::*;
30pub use registry::*;
31pub use replay::*;
32pub use subagent::*;
33pub use telemetry::*;
34
35use harness_compactor::{CALIBRATION_KEY, DefaultCompactor};
36use harness_core::{
37 Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
38 Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
39 StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
40};
41use harness_hooks::HookBus;
42use std::collections::HashMap;
43use std::sync::Arc;
44use std::time::Duration;
45
46/// Governs the loop's stuck-detector. When the model repeats the *same* tool
47/// call (name + args) round after round without making progress, the loop first
48/// nudges it to change approach, then terminates cleanly with [`Outcome::Stuck`]
49/// rather than burning the rest of the budget spinning on a loop.
50///
51/// Enabled by default with conservative thresholds — the calls must be
52/// *byte-identical*, so genuine "read the same file twice" work never trips it.
53#[derive(Debug, Clone)]
54pub struct StuckPolicy {
55 pub enabled: bool,
56 /// Consecutive identical tool-call rounds before injecting a "you are
57 /// repeating yourself, change your approach" feedback signal.
58 pub nudge_after: u32,
59 /// Consecutive identical rounds before terminating with [`Outcome::Stuck`].
60 pub abort_after: u32,
61}
62
63impl Default for StuckPolicy {
64 fn default() -> Self {
65 Self {
66 enabled: true,
67 nudge_after: 3,
68 abort_after: 6,
69 }
70 }
71}
72
73/// A ceiling on how much of one tool result reaches the context.
74///
75/// A single call can return more than the whole conversation: a lock file, a
76/// `SELECT *`, an MCP tool the framework does not control. Measured on a real
77/// run, "search these files for a word" cost 53,487 input tokens because one
78/// `read_file` returned a lock file — the model then paid for it on every
79/// subsequent turn, and compaction started throwing away real history to make
80/// room. A per-result ceiling is the only place to stop that: the tools cannot
81/// all be trusted (third-party MCP), and the compactor only runs after the
82/// damage is in the context.
83#[derive(Debug, Clone)]
84pub struct ToolResultPolicy {
85 /// Max serialized bytes of a single tool result. `None` disables the guard.
86 /// Default ~24 KiB — roughly 6k tokens of English, generous for a file page
87 /// or a query result, far below what blows a window.
88 pub max_bytes: Option<usize>,
89 /// Replace the payload of a read-only call that exactly repeats an earlier
90 /// one in the same run, when nothing has modified the world in between.
91 ///
92 /// [`StuckPolicy`] only sees *consecutive* identical rounds. Reading a file
93 /// at iteration 1 and again at iteration 5 is not that, and looks like
94 /// progress — but the same bytes land in the context twice and the model
95 /// learns nothing the second time. Measured on a real run, model wait was
96 /// 36.7s against 3ms of tool execution: a repeat costs context, not time,
97 /// so what is suppressed is the payload, not the call.
98 ///
99 /// Only `ToolRisk::ReadOnly` qualifies (`Network` is a separate risk, and an
100 /// external endpoint may answer differently), and any non-read-only call
101 /// clears the record — after a write, re-reading is the correct move.
102 ///
103 /// **Off by default, on the evidence.** Measured on the completion
104 /// benchmark: ceilings alone solved 6/6 tasks for 160k effective tokens;
105 /// adding repeat-suppression cut that to 32k — and lost a task. An agent
106 /// working through a file larger than one page re-reads it because it cannot
107 /// hold it, gets told it already has the answer, and does not: the content
108 /// was paged away. Five times cheaper is not worth a task a framework could
109 /// otherwise do, so this is opt-in for callers who would rather have the
110 /// tokens. (Suppressing from the *third* identical call rather than the
111 /// second would likely keep most of the saving without the failure — it
112 /// needs measuring before it becomes the default.)
113 pub dedupe_repeats: bool,
114 /// When a result exceeds `max_bytes`, save the *full* payload to a file
115 /// under `.harness/spill/` in the workspace and inline a bounded preview
116 /// plus the path, instead of cutting the tail off and throwing it away.
117 ///
118 /// Truncation destroys information: the model is told "narrow your
119 /// request", but the bytes it needed may be exactly the ones dropped, and
120 /// the only recovery is re-running the call to be truncated again.
121 /// Measured on the completion benchmark, that loop is visible as a single
122 /// task paying 184k input tokens. Spilling keeps the ceiling — the context
123 /// gets a preview, never the flood — while the whole result stays
124 /// retrievable through the file tools the agent already has (`read_file`
125 /// with offset/limit, `grep` on the spill path), because the spill lives
126 /// inside the workspace jail. Borrowed from DeepSeek Harness's `spill`
127 /// family (preview + retrieval locator), which is the same judgement.
128 ///
129 /// Costs nothing until it fires: the write happens only on the oversized
130 /// path, which the default ceiling makes rare. When the write fails (e.g.
131 /// read-only workspace) the guard falls back to plain truncation.
132 pub spill: bool,
133}
134
135impl Default for ToolResultPolicy {
136 fn default() -> Self {
137 Self {
138 max_bytes: Some(24 * 1024),
139 dedupe_repeats: false,
140 spill: true,
141 }
142 }
143}
144
145/// Governs *when* and *how far* the loop compacts context. Hysteresis: only
146/// start compacting once usage crosses `high_water`, and stop as soon as it's
147/// back under `target` — instead of running every stage above a threshold on
148/// every turn. This avoids over-compacting (needlessly reaching the lossy,
149/// main-model `AutoCompact` stage) and, because compaction rewrites history and
150/// invalidates the provider prefix cache, avoids nibbling the context every
151/// single turn.
152#[derive(Debug, Clone)]
153pub struct CompactPolicy {
154 /// Start compacting when `used/window` exceeds this. Default 0.75.
155 pub high_water: f32,
156 /// Stop as soon as `used/window` is back at/under this. Default 0.55.
157 pub target: f32,
158}
159
160impl Default for CompactPolicy {
161 fn default() -> Self {
162 Self {
163 high_water: 0.75,
164 target: 0.55,
165 }
166 }
167}
168
169/// Inline preview size for a spilled result. Deliberately smaller than the
170/// ceiling: the point of spilling is that the context gets a *glimpse* and a
171/// path, not four-fifths of the flood.
172const SPILL_PREVIEW_BYTES: usize = 4 * 1024;
173
174/// First `n` bytes of `s`, cut on a char boundary.
175fn head_of(s: &str, n: usize) -> &str {
176 let mut end = n.min(s.len());
177 while end > 0 && !s.is_char_boundary(end) {
178 end -= 1;
179 }
180 &s[..end]
181}
182
183/// The string field that dominates an oversized result, if one does.
184///
185/// Most oversized results are one big string in a small envelope — a file
186/// body, a command's stdout — and the right spill for those is the *raw text*:
187/// multi-line, so `read_file`'s line paging and `grep`'s line matching work on
188/// it. Spilling the serialized JSON instead would fold the whole payload onto
189/// one escaped line that line-oriented tools can neither page nor match
190/// (`read_file` pages by line; a 68 KB single-line file is unreachable past
191/// the first 16 KB). Returns the dotted path and the string when one field is
192/// ≥ 80% of the serialized size.
193fn dominant_string(v: &serde_json::Value, total: usize) -> Option<(String, &str)> {
194 fn walk<'a>(v: &'a serde_json::Value, path: &str, best: &mut Option<(String, &'a str)>) {
195 match v {
196 serde_json::Value::String(s) => {
197 if best.as_ref().is_none_or(|(_, b)| s.len() > b.len()) {
198 *best = Some((path.to_string(), s.as_str()));
199 }
200 }
201 serde_json::Value::Object(m) => {
202 for (k, x) in m {
203 let p = if path.is_empty() {
204 k.clone()
205 } else {
206 format!("{path}.{k}")
207 };
208 walk(x, &p, best);
209 }
210 }
211 serde_json::Value::Array(a) => {
212 for (i, x) in a.iter().enumerate() {
213 walk(x, &format!("{path}[{i}]"), best);
214 }
215 }
216 _ => {}
217 }
218 }
219 let mut best = None;
220 walk(v, "", &mut best);
221 best.filter(|(_, s)| s.len() * 5 >= total * 4)
222}
223
224/// Replace the value at a `dominant_string` dotted path with `with`.
225fn replace_at(v: &mut serde_json::Value, path: &str, with: serde_json::Value) {
226 let mut cur = v;
227 let mut rest = path;
228 loop {
229 // Next segment: `key`, `key[i]`, or a bare `[i]`.
230 let (seg, tail) = match rest.find('.') {
231 Some(dot) => (&rest[..dot], &rest[dot + 1..]),
232 None => (rest, ""),
233 };
234 let (key, idx) = match seg.find('[') {
235 Some(b) => (&seg[..b], seg[b + 1..seg.len() - 1].parse::<usize>().ok()),
236 None => (seg, None),
237 };
238 if !key.is_empty() {
239 match cur.get_mut(key) {
240 Some(next) => cur = next,
241 None => return,
242 }
243 }
244 if let Some(i) = idx {
245 match cur.get_mut(i) {
246 Some(next) => cur = next,
247 None => return,
248 }
249 }
250 if tail.is_empty() {
251 *cur = with;
252 return;
253 }
254 rest = tail;
255 }
256}
257
258/// Persist an oversized result under `.harness/spill/` in the workspace and
259/// build the inline marker. `None` on any IO failure — the caller falls back
260/// to truncation, because a guard that can error a run to protect a context
261/// window has its priorities backwards.
262fn spill_oversized(
263 action: &Action,
264 content: &serde_json::Value,
265 serialized: &str,
266 root: &std::path::Path,
267) -> Option<serde_json::Value> {
268 let dir = root.join(".harness").join("spill");
269 std::fs::create_dir_all(&dir).ok()?;
270 let id: String = action
271 .call_id
272 .chars()
273 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
274 .take(48)
275 .collect();
276 let id = if id.is_empty() { "call".into() } else { id };
277
278 let (rel, preview, meta) = match dominant_string(content, serialized.len()) {
279 // One big string in a small envelope: spill the raw text, keep the
280 // envelope inline with the big field swapped for a pointer.
281 Some((field, text)) => {
282 let rel = format!(".harness/spill/{id}-{}.txt", action.tool);
283 std::fs::write(root.join(&rel), text).ok()?;
284 let mut meta = content.clone();
285 replace_at(
286 &mut meta,
287 &field,
288 serde_json::Value::String(format!("[{} bytes spilled to {rel}]", text.len())),
289 );
290 (
291 rel,
292 head_of(text, SPILL_PREVIEW_BYTES).to_string(),
293 Some(meta),
294 )
295 }
296 // Structured payload (e.g. a long match list): spill pretty-printed,
297 // one element per line, so line-oriented retrieval still works.
298 None => {
299 let rel = format!(".harness/spill/{id}-{}.json", action.tool);
300 let pretty =
301 serde_json::to_string_pretty(content).unwrap_or_else(|_| serialized.to_string());
302 std::fs::write(root.join(&rel), &pretty).ok()?;
303 (rel, head_of(&pretty, SPILL_PREVIEW_BYTES).to_string(), None)
304 }
305 };
306 tracing::warn!(
307 target: "harness.telemetry",
308 event = "tool.result.spilled",
309 "gen_ai.tool.name" = %action.tool,
310 bytes = serialized.len(),
311 path = %rel,
312 );
313 let mut marker = serde_json::json!({
314 "spilled": true,
315 "tool": action.tool,
316 "bytes_total": serialized.len(),
317 "path": rel,
318 "preview": preview,
319 "note": format!(
320 "This result was {} bytes — too large to inline, so the FULL content was \
321 saved to '{rel}' (workspace-relative). Nothing was lost. Retrieve exactly \
322 the part you need: grep(path=\"{rel}\", pattern=...) or \
323 read_file(path=\"{rel}\", offset=..., limit=...). Do not repeat the \
324 original call — it will spill again.",
325 serialized.len()
326 ),
327 });
328 // The envelope metadata (paths, flags) rides along when it is small; a
329 // pathological envelope that is itself oversized is dropped, not inlined.
330 if let Some(meta) = meta
331 && meta.to_string().len() <= SPILL_PREVIEW_BYTES
332 {
333 marker["meta"] = meta;
334 }
335 Some(marker)
336}
337
338/// A stable fingerprint of a round's tool calls (names + args, ignoring the
339/// volatile call id). Two rounds with the same fingerprint asked for the exact
340/// same actions — the signal the stuck-detector keys on.
341fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
342 calls
343 .iter()
344 .map(|c| format!("{}({})", c.name, c.args))
345 .collect::<Vec<_>>()
346 .join("|")
347}
348
349/// Where a run finished. Each variant is `#[non_exhaustive]` so new *fields*
350/// don't break downstream matches — always include `..` when destructuring.
351#[derive(Debug, Clone)]
352pub enum Outcome {
353 /// Model returned text with no tool calls (natural end).
354 #[non_exhaustive]
355 Done {
356 text: Option<String>,
357 iters: u32,
358 tools_called: u32,
359 usage: harness_core::Usage,
360 /// What the acceptance checks said. `None` means nothing was asked —
361 /// so "the model stopped" is all this outcome claims.
362 verified: Option<Verdict>,
363 },
364 /// Policy budget exhausted before the model stopped requesting tools.
365 /// Carries everything we know so the caller can recover partial work
366 /// (saved notes, files written by tools, the last assistant text, etc.)
367 /// instead of seeing a single bare "budget out" string.
368 #[non_exhaustive]
369 BudgetExhausted {
370 iters: u32,
371 last_text: Option<String>,
372 tools_called: u32,
373 usage: harness_core::Usage,
374 },
375 /// The agent got stuck: it repeated the *same* tool call for
376 /// `StuckPolicy::abort_after` consecutive rounds without progress, so the
377 /// loop terminated early to save the rest of the budget. Carries partial
378 /// work (last text, files already written by tools) like `BudgetExhausted`.
379 #[non_exhaustive]
380 Stuck {
381 /// Human-readable reason, e.g. "repeated `read_file(...)` 6× without progress".
382 reason: String,
383 /// How many consecutive identical rounds were observed.
384 repeated: u32,
385 iters: u32,
386 last_text: Option<String>,
387 tools_called: u32,
388 usage: harness_core::Usage,
389 },
390}
391
392/// The agent loop.
393pub struct AgentLoop<M: Model> {
394 pub model: M,
395 pub tools: ToolRegistry,
396 pub guides: Vec<Arc<dyn Guide>>,
397 pub sensors: Vec<Arc<dyn Sensor>>,
398 pub hooks: HookBus,
399 pub compactor: Arc<dyn Compactor>,
400 /// A deadline on each individual tool call. One hung call — a network tool
401 /// on a dead endpoint, a shell command waiting on stdin — otherwise takes
402 /// the whole run down with it, and the host's only recourse is a run-level
403 /// timeout that throws away every turn of finished work (measured on the
404 /// completion benchmark: a run that had already done the job was billed as
405 /// a 0-token timeout). A per-call deadline converts the hang into an error
406 /// *result* the model sees and can route around. `None` disables. The
407 /// default is generous — 120s covers a slow build — because a false
408 /// deadline on a legitimately long tool is worse than a late one.
409 pub tool_timeout: Option<Duration>,
410 /// Default response format applied to every run unless overridden by
411 /// `run_typed`. See [`ResponseFormat`].
412 pub response_format: ResponseFormat,
413 /// When `true`, the loop drives each model turn via `Model::stream()`
414 /// instead of `complete()`, firing `Event::ModelTokenDelta` for each
415 /// text fragment. Tool-call deltas are still assembled inside the loop;
416 /// only the terminal `ModelOutput` shape is observable downstream.
417 pub streaming: bool,
418 /// Optional cross-session recall store. When set, the loop captures every
419 /// turn and the `session_search` tool is registered. See `with_recall`.
420 pub recall: Option<Arc<dyn harness_core::RecallStore>>,
421 /// When true (and `recall` is set), a `RecallGuide` auto-injects top-k
422 /// past context at session start.
423 pub recall_auto_inject: bool,
424 pub learning: Option<LearningConfig>,
425 /// Loop-detection policy. Enabled by default — see [`StuckPolicy`].
426 pub stuck: StuckPolicy,
427 /// Context-compaction hysteresis. See [`CompactPolicy`].
428 pub compaction: CompactPolicy,
429 /// Ceiling on a single tool result. See [`ToolResultPolicy`].
430 pub tool_results: ToolResultPolicy,
431 /// Conditions the run must satisfy before the loop reports success. The
432 /// model stopping is evidence it *believes* it is finished; these say
433 /// whether it is. See [`acceptance`].
434 pub acceptance: Vec<Arc<dyn Acceptance>>,
435 /// How many times a failed acceptance is handed back to the model before
436 /// the loop gives up and reports what there is. One is usually enough: a
437 /// model that ignores the first correction rarely takes the second.
438 pub acceptance_retries: u32,
439 /// System instruction injected into every run's `Context.system` (unless the
440 /// built context already carries its own). Set via [`with_system`](Self::with_system).
441 pub system: Vec<Block>,
442}
443
444impl AgentLoop<harness_core::DynModel> {
445 /// Build a loop from a boxed model — what every model factory hands back
446 /// (`ApiKind::build`, a router, anything stored behind a trait object).
447 ///
448 /// `Arc<dyn Model>` deliberately does not implement `Model` (see
449 /// [`DynModel`](harness_core::DynModel) for why), so `AgentLoop::new` cannot
450 /// take one. Without this constructor every caller writes the wrapper
451 /// themselves, and the first thing a new user meets is a trait-bound error
452 /// naming a type they have never heard of.
453 ///
454 /// ```ignore
455 /// let model = ApiKind::OpenAI.build(base_url, model_id, key);
456 /// let agent = AgentLoop::boxed(model).with_tool(Arc::new(ReadFile));
457 /// ```
458 pub fn boxed(model: Arc<dyn Model>) -> Self {
459 Self::new(harness_core::DynModel(model))
460 }
461}
462
463impl<M: Model> AgentLoop<M> {
464 pub fn new(model: M) -> Self {
465 Self {
466 model,
467 tools: ToolRegistry::new(),
468 guides: Vec::new(),
469 sensors: Vec::new(),
470 hooks: HookBus::new(),
471 compactor: Arc::new(DefaultCompactor::new()),
472 tool_timeout: Some(Duration::from_secs(120)),
473 response_format: ResponseFormat::Free,
474 streaming: false,
475 recall: None,
476 recall_auto_inject: false,
477 learning: None,
478 stuck: StuckPolicy::default(),
479 compaction: CompactPolicy::default(),
480 // On by default, because the failure it catches is invisible: a
481 // turn that produced nothing is reported as a turn that finished.
482 tool_results: ToolResultPolicy::default(),
483 acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
484 acceptance_retries: 1,
485 system: Vec::new(),
486 }
487 }
488
489 /// Set a system instruction applied to every run (into `Context.system`) —
490 /// e.g. "answer only via the governed tools; never claim you can't access
491 /// data; never invent numbers". This is the first-class seam for a system
492 /// prompt; small local models in particular need it to reliably call tools
493 /// instead of refusing or hallucinating.
494 pub fn with_system(mut self, text: impl Into<String>) -> Self {
495 self.system = vec![Block::Text(text.into())];
496 self
497 }
498
499 /// Override the loop-detection policy (thresholds, or disable entirely).
500 pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
501 self.stuck = policy;
502 self
503 }
504
505 /// Override the compaction hysteresis policy. See [`CompactPolicy`].
506 /// Set the ceiling on a single tool result. See [`ToolResultPolicy`].
507 pub fn with_tool_result_policy(mut self, policy: ToolResultPolicy) -> Self {
508 self.tool_results = policy;
509 self
510 }
511
512 pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
513 self.compaction = policy;
514 self
515 }
516
517 /// Opt in to streaming the model's terminal turn token-by-token via
518 /// `Model::stream()`. Hooks subscribed to `Event::ModelTokenDelta` see
519 /// each fragment as it arrives; the rest of the loop is unchanged.
520 pub fn with_streaming(mut self, enable: bool) -> Self {
521 self.streaming = enable;
522 self
523 }
524
525 /// Add a condition the run must satisfy before it can report success.
526 pub fn with_acceptance(mut self, a: Arc<dyn Acceptance>) -> Self {
527 self.acceptance.push(a);
528 self
529 }
530
531 /// Replace the acceptance set outright (including the default).
532 pub fn with_acceptance_set(mut self, set: Vec<Arc<dyn Acceptance>>) -> Self {
533 self.acceptance = set;
534 self
535 }
536
537 pub fn with_acceptance_retries(mut self, n: u32) -> Self {
538 self.acceptance_retries = n;
539 self
540 }
541
542 pub fn with_tool_timeout(mut self, t: Option<Duration>) -> Self {
543 self.tool_timeout = t;
544 self
545 }
546
547 pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
548 self.compactor = c;
549 self
550 }
551
552 pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
553 self.tools.insert(t);
554 self
555 }
556
557 pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
558 self.guides.push(g);
559 self
560 }
561
562 pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
563 self.sensors.push(s);
564 self
565 }
566
567 pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
568 self.hooks.register(h);
569 self
570 }
571
572 /// Pull in every `#[hook]`-registered hook.
573 pub fn with_macro_hooks(mut self) -> Self {
574 self.hooks = self.hooks.with_macro_hooks_take();
575 self
576 }
577
578 /// Enable cross-session recall: capture every turn into `store` and
579 /// register the `session_search` tool. Owner + session id are read from
580 /// `world.profile.extra["recall_owner"|"recall_session"]` at run time.
581 pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
582 self.tools
583 .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
584 self.recall = Some(store);
585 self
586 }
587
588 /// After `with_recall`, also auto-inject top-k relevant past context at
589 /// session start (off by default — tool-only is prompt-cache friendly).
590 pub fn auto_inject(mut self) -> Self {
591 self.recall_auto_inject = true;
592 self
593 }
594
595 /// Enable the self-evolving learning loop: after a session that made
596 /// `>= cfg.nudge_interval` tool calls, fork a review subagent (white-listed to
597 /// `cfg.tools`) to update skills + memory from the transcript. Best-effort.
598 pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
599 self.learning = Some(cfg);
600 self
601 }
602
603 /// Set the default response format for all runs through this loop. See
604 /// [`ResponseFormat`]. For typed deserialisation, prefer `run_typed::<T>()`.
605 pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
606 self.response_format = fmt;
607 self
608 }
609
610 /// Shortcut for `with_response_format(ResponseFormat::JsonSchema { name, schema })`.
611 /// Accepts a raw `serde_json::Value` so callers can hand-roll the schema or
612 /// pull it from `schemars::schema_for!(T)`.
613 pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
614 self.with_response_format(ResponseFormat::JsonSchema {
615 name: name.into(),
616 schema,
617 })
618 }
619
620 pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
621 let max = harness_core::Policy::default().max_iters;
622 self.run_with_max_iters(task, world, max).await
623 }
624
625 pub async fn run_with_max_iters(
626 &self,
627 task: Task,
628 world: &mut World,
629 max_iters: u32,
630 ) -> Result<Outcome, HarnessError> {
631 self.run_with_seed_history(task, Vec::new(), world, max_iters)
632 .await
633 }
634
635 /// Run the agent and deserialise the terminal reply into `T`.
636 ///
637 /// The schema for `T` is derived via `schemars::schema_for!(T)` and
638 /// installed as `ResponseFormat::JsonSchema` for this run only — any
639 /// pre-existing `self.response_format` is ignored. On success the
640 /// returned `T` is parsed from `Outcome::Done.text` (or, on budget
641 /// exhaustion, from `Outcome::BudgetExhausted.last_text`).
642 ///
643 /// Errors:
644 /// - `HarnessError::Other` if the model returns no text at all
645 /// - `HarnessError::Other` if `serde_json::from_str::<T>(text)` fails —
646 /// the original text is included in the message for debugging.
647 pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
648 where
649 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
650 {
651 let max = harness_core::Policy::default().max_iters;
652 self.run_typed_with_max_iters::<T>(task, world, max).await
653 }
654
655 /// Like `run_typed` but with explicit `max_iters`.
656 pub async fn run_typed_with_max_iters<T>(
657 &self,
658 task: Task,
659 world: &mut World,
660 max_iters: u32,
661 ) -> Result<T, HarnessError>
662 where
663 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
664 {
665 let schema_root = schemars::schema_for!(T);
666 let schema = serde_json::to_value(&schema_root)
667 .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
668 let name = std::any::type_name::<T>()
669 .rsplit("::")
670 .next()
671 .unwrap_or("response")
672 .to_string();
673 let fmt = ResponseFormat::JsonSchema { name, schema };
674 let outcome = self
675 .run_with_response_format(task, world, max_iters, fmt)
676 .await?;
677 let text = match outcome {
678 Outcome::Done { text: Some(t), .. }
679 | Outcome::BudgetExhausted {
680 last_text: Some(t), ..
681 }
682 | Outcome::Stuck {
683 last_text: Some(t), ..
684 } => t,
685 Outcome::Done { text: None, .. } => {
686 return Err(HarnessError::Other(
687 "run_typed: model returned no text".into(),
688 ));
689 }
690 Outcome::Stuck {
691 last_text: None, ..
692 } => {
693 return Err(HarnessError::Other(
694 "run_typed: agent stuck with no text".into(),
695 ));
696 }
697 Outcome::BudgetExhausted {
698 last_text: None, ..
699 } => {
700 return Err(HarnessError::Other(
701 "run_typed: budget exhausted with no text".into(),
702 ));
703 }
704 };
705 serde_json::from_str::<T>(&text).map_err(|e| {
706 HarnessError::Other(format!(
707 "run_typed: decode {} failed: {e} — raw text was: {text}",
708 std::any::type_name::<T>()
709 ))
710 })
711 }
712
713 /// Run with a one-off `ResponseFormat` override (doesn't touch `self`).
714 pub async fn run_with_response_format(
715 &self,
716 task: Task,
717 world: &mut World,
718 max_iters: u32,
719 fmt: ResponseFormat,
720 ) -> Result<Outcome, HarnessError> {
721 // Borrow checker won't let us swap `self.response_format` because
722 // `self` is `&`. Easiest workaround: hand-roll the same setup that
723 // `run_with_seed_history` does, but with our `fmt`. We do this by
724 // calling through a private helper.
725 self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
726 .await
727 }
728
729 async fn run_with_seed_history_and_format(
730 &self,
731 task: Task,
732 seed: Vec<Turn>,
733 world: &mut World,
734 max_iters: u32,
735 fmt_override: Option<ResponseFormat>,
736 ) -> Result<Outcome, HarnessError> {
737 let mut ctx = Context::new(task);
738 ctx.policy.max_iters = max_iters;
739 ctx.tools = self.tools.schemas();
740 ctx.history = seed;
741 ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
742 self.run_built_context(ctx, world).await
743 }
744
745 /// Like `run_with_max_iters` but seeds `ctx.history` with `seed` **before**
746 /// the current user task is appended. Use this for multi-turn REPLs so
747 /// prior conversation lives in `ctx.history` (where the Compactor can see
748 /// it) instead of being concatenated into `task.description` (where it
749 /// previously bypassed compaction entirely — see audit #2).
750 pub async fn run_with_seed_history(
751 &self,
752 task: Task,
753 seed: Vec<Turn>,
754 world: &mut World,
755 max_iters: u32,
756 ) -> Result<Outcome, HarnessError> {
757 self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
758 .await
759 }
760
761 /// Like [`run_with_seed_history`](Self::run_with_seed_history) but also seeds
762 /// `ctx.metadata` with per-request key/values. Hooks and a
763 /// [`ModelRouter`](harness_models::ModelRouter) read this map — e.g.
764 /// `audit.actor` / `audit.session` for the audit trail, or
765 /// `router.keep_local` to pin a request to the local model. This is the
766 /// entry point a serving layer uses to pass caller identity and routing
767 /// flags into a single, shared, reused loop.
768 pub async fn run_with_seed_and_metadata(
769 &self,
770 task: Task,
771 seed: Vec<Turn>,
772 metadata: std::collections::BTreeMap<String, serde_json::Value>,
773 world: &mut World,
774 max_iters: u32,
775 ) -> Result<Outcome, HarnessError> {
776 let mut ctx = Context::new(task);
777 ctx.policy.max_iters = max_iters;
778 ctx.tools = self.tools.schemas();
779 ctx.history = seed;
780 ctx.metadata = metadata;
781 ctx.response_format = self.response_format.clone();
782 self.run_built_context(ctx, world).await
783 }
784
785 /// Start a persistent multi-turn [`Session`]. Each `turn` re-runs the loop
786 /// against the accumulated history with a **stable prefix** (system +
787 /// name-sorted tool schemas), so a provider's prefix cache (e.g. DeepSeek's,
788 /// ~10% price on cache-hit tokens) hits across turns instead of paying full
789 /// price to re-read the same bytes every round. For maximum hit rate, keep
790 /// your guides' output stable (put per-turn volatile context in the message,
791 /// not a recomputed system guide).
792 pub fn session(&self) -> Session<'_, M> {
793 Session {
794 loop_: self,
795 history: Vec::new(),
796 max_iters: harness_core::Policy::default().max_iters,
797 }
798 }
799
800 /// Inner ReAct loop on an already-prepared `Context`. Use the public
801 /// `run*` methods unless you need to inject a non-standard `Context`
802 /// (e.g. `run_with_response_format` does to apply a one-off
803 /// `ResponseFormat` without mutating `self`).
804 async fn run_built_context(
805 &self,
806 mut ctx: Context,
807 world: &mut World,
808 ) -> Result<Outcome, HarnessError> {
809 if ctx.system.is_empty() && !self.system.is_empty() {
810 ctx.system = self.system.clone();
811 }
812
813 // Size the context budget to the model actually in use.
814 //
815 // Compaction fires at a fraction of `max_input_tokens`, so a value
816 // unrelated to the model is wrong in one of two directions: too high and
817 // the provider rejects the request before the compactor ever runs (a 32k
818 // model under the 150k default would need 112,500 tokens to trigger, and
819 // it cannot hold that many); too low and the loop discards history it
820 // could have kept. Nothing read `ModelInfo::context_window` before this —
821 // the framework's own defaults disagreed, 150,000 against 128,000.
822 //
823 // Only when the caller left the default in place; an explicit policy is
824 // a decision and stays untouched. The output allowance is reserved,
825 // because the window is shared between the prompt and the reply.
826 if ctx.policy.max_input_tokens == harness_core::Policy::default().max_input_tokens {
827 let window = self.model.info().context_window;
828 if window > 0 {
829 // Reserve room for the reply, but never let the reservation eat
830 // the window: an 8k model with the default 8k output allowance
831 // would leave 0 tokens for input, and every turn — a 26-token
832 // one included — would run all five compaction stages against a
833 // budget of 1. Cap the reservation at a quarter of the window.
834 let reserve = ctx.policy.max_output_tokens.min(window / 4);
835 ctx.policy.max_input_tokens = window.saturating_sub(reserve).max(1);
836 }
837 }
838
839 self.hooks.fire(
840 &Event::SessionStart {
841 source: SessionSource::Startup,
842 },
843 world,
844 );
845
846 // ── recall: resolve owner/session, ensure the session row ──
847 let (recall_owner, recall_session) = if self.recall.is_some() {
848 use std::sync::atomic::Ordering;
849 let owner = crate::recall_owner(world);
850 let session = world
851 .profile
852 .extra
853 .get("recall_session")
854 .and_then(|v| v.as_str())
855 .map(|s| s.to_string())
856 .unwrap_or_else(|| {
857 format!(
858 "sess-{}-{}",
859 world.clock.now_ms(),
860 RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
861 )
862 });
863 if let Some(store) = &self.recall {
864 let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
865 if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
866 tracing::warn!(error = %e, "recall ensure_session failed");
867 }
868 }
869 (owner, session)
870 } else {
871 (String::new(), String::new())
872 };
873
874 let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
875 if self.recall.is_none() {
876 tracing::warn!(
877 "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
878 );
879 None
880 } else {
881 self.recall
882 .clone()
883 .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
884 }
885 } else {
886 None
887 };
888 let all_guides: Vec<&Arc<dyn Guide>> =
889 self.guides.iter().chain(recall_guide.iter()).collect();
890 for g in &all_guides {
891 if g.scope().matches(&ctx.task) {
892 self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
893 g.apply(&mut ctx, world).await?;
894 self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
895 }
896 }
897
898 ctx.history.push(Turn {
899 role: TurnRole::User,
900 blocks: vec![Block::Text(ctx.task.description.clone())],
901 });
902
903 if self.recall.is_some() {
904 self.recall_append(
905 &recall_owner,
906 &recall_session,
907 harness_core::RecallMessage::new(
908 "user",
909 ctx.task.description.clone(),
910 world.clock.now_ms(),
911 ),
912 )
913 .await;
914 }
915
916 // Running totals — surface to caller even on BudgetExhausted.
917 let mut tools_called: u32 = 0;
918 let mut total_usage = harness_core::Usage::default();
919 let mut last_text: Option<String> = None;
920
921 // Stuck-detector state: the previous round's tool-call fingerprint and
922 // how many consecutive rounds have repeated it.
923 let mut last_fingerprint: Option<String> = None;
924 let mut repeat_count: u32 = 0;
925 // Read-only calls already answered this run, cleared whenever anything
926 // mutates the world. See `ToolResultPolicy::dedupe_repeats`.
927 let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
928 // How many times the model has stopped mid-work with nothing to show.
929 let mut acceptance_retries_left = self.acceptance_retries;
930
931 for iter in 0..ctx.policy.max_iters {
932 self.hooks.fire(&Event::Heartbeat { iter }, world);
933
934 // Compaction with hysteresis: only once over the high-water mark,
935 // then escalate stage-by-stage, re-estimating after each, and stop
936 // the moment we're back under target. Avoids over-compacting to the
937 // lossy AutoCompact stage and avoids rewriting history (→ prefix
938 // cache miss) every turn. Token estimate is calibrated against the
939 // last real `input_tokens` via `CALIBRATION_KEY`.
940 let mut budget = self.compactor.budget(&ctx);
941 if budget.ratio() > self.compaction.high_water {
942 for stage in CompactionStage::ALL {
943 if budget.ratio() <= self.compaction.target {
944 break;
945 }
946 self.hooks.fire(&Event::PreCompact { stage }, world);
947 let before = budget.used;
948 self.compactor.compact(stage, &mut ctx).await?;
949 budget = self.compactor.budget(&ctx);
950 self.hooks.fire(
951 &Event::PostCompact {
952 stage,
953 before,
954 after: budget.used,
955 },
956 world,
957 );
958 }
959 }
960
961 // Per-iteration guides — recall-style adapters that want to
962 // refresh their injected context every turn (e.g. MemoryGuide
963 // re-recalling against the latest user message). Default
964 // `apply_before_iter` is a no-op, so this loop is cheap for
965 // guides that don't override it.
966 for g in &all_guides {
967 if g.scope().matches(&ctx.task)
968 && let Err(e) = g.apply_before_iter(&mut ctx, world).await
969 {
970 tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
971 }
972 }
973
974 self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
975 let out = if self.streaming {
976 self.complete_via_stream(&ctx, world).await?
977 } else {
978 self.model.complete(&ctx).await?
979 };
980 self.hooks.fire(&Event::PostModel { out: &out }, world);
981
982 // Calibrate the compactor against ground truth: `ctx` still holds
983 // exactly what we just sent, so `budget().used` is the estimate for
984 // it. Nudge the stored correction so estimate·correction ≈ the
985 // model's real `input_tokens` next time. Self-correcting (converges
986 // even as the raw estimate drifts); clamped so one odd turn can't
987 // blow it up. This is what makes compaction fire at the right moment
988 // for *this* model + language instead of a blind char heuristic.
989 if out.usage.input_tokens > 0 {
990 let used = self.compactor.budget(&ctx).used;
991 if used > 0 {
992 let prev = ctx
993 .metadata
994 .get(CALIBRATION_KEY)
995 .and_then(|v| v.as_f64())
996 .filter(|f| f.is_finite() && *f > 0.0)
997 .unwrap_or(1.0);
998 let next =
999 (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
1000 ctx.metadata
1001 .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
1002 }
1003 }
1004
1005 // Accumulate usage even if the run later exhausts budget.
1006 total_usage.input_tokens += out.usage.input_tokens;
1007 total_usage.output_tokens += out.usage.output_tokens;
1008 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1009 if let Some(t) = &out.text {
1010 last_text = Some(t.clone());
1011 }
1012 ctx.push_model_output(&out);
1013
1014 if self.recall.is_some() {
1015 let calls = if out.tool_calls.is_empty() {
1016 None
1017 } else {
1018 serde_json::to_string(&out.tool_calls).ok()
1019 };
1020 let mut m = harness_core::RecallMessage::new(
1021 "assistant",
1022 out.text.clone().unwrap_or_default(),
1023 world.clock.now_ms(),
1024 );
1025 m.tool_calls = calls;
1026 self.recall_append(&recall_owner, &recall_session, m).await;
1027 }
1028
1029 if out.tool_calls.is_empty() {
1030 // The model stopping is its opinion that the work is done.
1031 // Before taking it as fact, run whatever the caller said
1032 // "done" actually means. A failure goes back as an instruction
1033 // and the loop carries on; the retry cap keeps a model that
1034 // ignores corrections from eating the budget.
1035 let mut verdict: Option<Verdict> = None;
1036 if !self.acceptance.is_empty() {
1037 // Record this turn first — the checks read the transcript.
1038 let mut probe = ctx.clone();
1039 probe.history.push(Turn {
1040 role: TurnRole::Assistant,
1041 blocks: out
1042 .text
1043 .as_deref()
1044 .filter(|t| !t.trim().is_empty())
1045 .map(|t| vec![Block::Text(t.to_string())])
1046 .unwrap_or_default(),
1047 });
1048 for check in &self.acceptance {
1049 let v = check.check(&probe, world).await;
1050 if !v.passed {
1051 tracing::info!(
1052 check = check.name(),
1053 reason = %v.reason,
1054 "acceptance failed"
1055 );
1056 verdict = Some(v);
1057 break;
1058 }
1059 }
1060 // Everything passed. Say so explicitly: "checked, and it
1061 // holds up" is a different claim from "nobody looked", and
1062 // the host has to be able to tell them apart.
1063 verdict = verdict.or_else(|| Some(Verdict::passed()));
1064 }
1065
1066 if let Some(v) = verdict.clone().filter(|v| !v.passed)
1067 && acceptance_retries_left > 0
1068 && iter + 1 < ctx.policy.max_iters
1069 {
1070 acceptance_retries_left -= 1;
1071 ctx.history.push(Turn {
1072 role: TurnRole::User,
1073 blocks: vec![Block::Text(v.reason)],
1074 });
1075 continue;
1076 }
1077
1078 self.hooks.fire(&Event::TaskCompleted, world);
1079 self.hooks.fire(&Event::SessionEnd, world);
1080 self.run_learning_review(&ctx, world, tools_called).await;
1081 // Thinking models (e.g. Qwen3 via Ollama) sometimes emit the
1082 // whole answer into the reasoning channel and leave `text`
1083 // empty. Fall back to the reasoning so the turn isn't blank —
1084 // but `verified` says whether anyone agreed it was done.
1085 let text = out
1086 .text
1087 .filter(|t| !t.trim().is_empty())
1088 .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
1089 return Ok(Outcome::Done {
1090 text,
1091 iters: iter + 1,
1092 tools_called,
1093 usage: total_usage,
1094 verified: verdict,
1095 });
1096 }
1097
1098 // ── stuck detection ─────────────────────────────────────────
1099 // The model asked for tools again. If it's the *same* request as
1100 // last round, it's spinning: nudge it to change tack, then abort
1101 // cleanly rather than burn the rest of the budget on the loop.
1102 if self.stuck.enabled {
1103 let fp = tool_call_fingerprint(&out.tool_calls);
1104 if last_fingerprint.as_ref() == Some(&fp) {
1105 repeat_count += 1;
1106 } else {
1107 repeat_count = 1;
1108 last_fingerprint = Some(fp);
1109 }
1110
1111 if repeat_count >= self.stuck.abort_after {
1112 let reason =
1113 format!("repeated the same tool call {repeat_count}× without progress");
1114 tracing::warn!(repeated = repeat_count, "stuck: aborting run");
1115 self.hooks.fire(&Event::SessionEnd, world);
1116 return Ok(Outcome::Stuck {
1117 reason,
1118 repeated: repeat_count,
1119 iters: iter + 1,
1120 last_text,
1121 tools_called,
1122 usage: total_usage,
1123 });
1124 }
1125
1126 if repeat_count == self.stuck.nudge_after {
1127 tracing::warn!(
1128 repeated = repeat_count,
1129 "stuck: nudging model to change approach"
1130 );
1131 ctx.push_feedback(vec![harness_core::Signal {
1132 severity: harness_core::Severity::Warn,
1133 origin: "stuck-detector".into(),
1134 message: format!(
1135 "You have issued the same tool call {repeat_count} rounds in a row \
1136 without making progress."
1137 ),
1138 agent_hint: Some(
1139 "Stop repeating it. Inspect the actual tool result/error, try a \
1140 different approach, or give your final answer with no tool call."
1141 .into(),
1142 ),
1143 auto_fix: None,
1144 location: None,
1145 }]);
1146 }
1147 }
1148
1149 // Parallel-safe prefetch: dispatch the *leading run* of read-only
1150 // tool calls concurrently (a mutating tool is a serial barrier).
1151 // The sequential loop below still processes every call in order —
1152 // hooks, sensors, and history stay ordered — only the dispatch IO
1153 // overlaps. Reads before any write are safe; anything at/after the
1154 // first mutating call runs on the normal path.
1155 let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
1156 {
1157 let lead: Vec<&_> = out
1158 .tool_calls
1159 .iter()
1160 .take_while(|c| {
1161 self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
1162 })
1163 .collect();
1164 if lead.len() > 1 {
1165 let futs = lead.iter().map(|c| {
1166 let mut w = world.clone();
1167 let action = Action {
1168 tool: c.name.clone(),
1169 call_id: c.id.clone(),
1170 args: c.args.clone(),
1171 };
1172 async move {
1173 let r = self.dispatch_bounded(&action, &mut w).await;
1174 (action.call_id, r)
1175 }
1176 });
1177 for (id, r) in futures::future::join_all(futs).await {
1178 prefetched.insert(id, r);
1179 }
1180 }
1181 }
1182
1183 for call in &out.tool_calls {
1184 let action = Action {
1185 tool: call.name.clone(),
1186 call_id: call.id.clone(),
1187 args: call.args.clone(),
1188 };
1189
1190 // PreToolUse hook can deny destructive actions
1191 if let HookOutcome::Deny { reason } = self
1192 .hooks
1193 .fire(&Event::PreToolUse { action: &action }, world)
1194 {
1195 ctx.history.push(Turn {
1196 role: TurnRole::Tool,
1197 blocks: vec![Block::ToolResult {
1198 call_id: action.call_id.clone(),
1199 content: serde_json::json!({
1200 "ok": false,
1201 "denied_by_hook": reason,
1202 }),
1203 }],
1204 });
1205 if self.recall.is_some() {
1206 self.recall_append(
1207 &recall_owner,
1208 &recall_session,
1209 harness_core::RecallMessage::new(
1210 "tool",
1211 format!("[denied by hook] {reason}"),
1212 world.clock.now_ms(),
1213 )
1214 .with_tool_name(action.tool.clone()),
1215 )
1216 .await;
1217 }
1218 continue;
1219 }
1220
1221 // Use the concurrently-prefetched result if we have one;
1222 // otherwise dispatch now.
1223 let result = if let Some(r) = prefetched.remove(&action.call_id) {
1224 r
1225 } else {
1226 self.dispatch_bounded(&action, world).await
1227 };
1228 tools_called += 1;
1229
1230 // Decide the final payload *before* announcing the result, so
1231 // hooks, telemetry and the context all describe the same thing:
1232 // an audit that logs a 200 KB blob the model never saw is not an
1233 // audit of what happened.
1234 let result = ToolResult {
1235 content: self.shape_result(&action, &result, &mut answered, &world.repo.root),
1236 ..result
1237 };
1238 self.hooks.fire(
1239 &Event::PostToolUse {
1240 action: &action,
1241 result: &result,
1242 },
1243 world,
1244 );
1245
1246 ctx.history.push(Turn {
1247 role: TurnRole::Tool,
1248 blocks: vec![Block::ToolResult {
1249 call_id: action.call_id.clone(),
1250 content: result.content.clone(),
1251 }],
1252 });
1253
1254 if self.recall.is_some() {
1255 let body = serde_json::to_string(&result.content).unwrap_or_default();
1256 self.recall_append(
1257 &recall_owner,
1258 &recall_session,
1259 harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
1260 .with_tool_name(action.tool.clone()),
1261 )
1262 .await;
1263 }
1264
1265 // run self-correct sensors
1266 let mut all_signals = Vec::new();
1267 for s in &self.sensors {
1268 if s.stage() != Stage::SelfCorrect {
1269 continue;
1270 }
1271 self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
1272 let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
1273 tracing::warn!(?e, "sensor failed");
1274 Vec::new()
1275 });
1276 self.hooks.fire(
1277 &Event::PostSensor {
1278 sensor: s.id(),
1279 signals: &sigs,
1280 },
1281 world,
1282 );
1283 all_signals.extend(sigs);
1284 }
1285 if !all_signals.is_empty() {
1286 let bundle = SignalSet::new(all_signals);
1287 let (patches, remaining) = bundle.partition_auto_fix();
1288
1289 // audit #7: each patch goes through PreAutoFix.
1290 // Hooks can Deny (skip silently). Default safelist on
1291 // RunCommand catches the obvious misuses with no hook.
1292 let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
1293 if !is_default_safe_fix(p) {
1294 tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
1295 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1296 return false;
1297 }
1298 match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
1299 HookOutcome::Deny { reason } => {
1300 tracing::warn!(?p, %reason, "auto-fix denied by hook");
1301 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1302 false
1303 }
1304 _ => true,
1305 }
1306 }).collect();
1307
1308 let applied = apply_patches(&approved, world).await;
1309 // Emit PostAutoFix for each approved patch with the application result.
1310 for (i, p) in approved.iter().enumerate() {
1311 self.hooks.fire(
1312 &Event::PostAutoFix {
1313 patch: p,
1314 applied: i < applied.len(),
1315 },
1316 world,
1317 );
1318 }
1319 if !applied.is_empty() {
1320 ctx.push_feedback(vec![harness_core::Signal {
1321 severity: harness_core::Severity::Hint,
1322 origin: "auto-fix".into(),
1323 message: format!(
1324 "applied {} auto-fix patch(es): {applied:?}",
1325 applied.len()
1326 ),
1327 agent_hint: Some(
1328 "re-check the affected files before continuing".into(),
1329 ),
1330 auto_fix: None,
1331 location: None,
1332 }]);
1333 }
1334 if remaining.has_blocking() {
1335 ctx.push_feedback(remaining.signals);
1336 }
1337 }
1338 }
1339 }
1340 // ── Budget exhausted ─────────────────────────────────────────
1341 // Force a final synthesis pass with tools DISABLED. Otherwise the
1342 // model often spins on tool calls right up to the budget cap and
1343 // never emits a text conclusion, leaving the caller with nothing
1344 // but `last_text` from some earlier intermediate turn (or None).
1345 //
1346 // The synthesis call is "free" — it costs one extra model call
1347 // beyond max_iters but doesn't count toward `iters`. The result
1348 // lands in `last_text` so callers display it as the answer.
1349 let synthesised = self
1350 .force_final_synthesis(&mut ctx, world, &mut total_usage)
1351 .await;
1352 if let Some(t) = synthesised {
1353 last_text = Some(t);
1354 }
1355
1356 self.hooks.fire(&Event::SessionEnd, world);
1357 self.run_learning_review(&ctx, world, tools_called).await;
1358 Ok(Outcome::BudgetExhausted {
1359 iters: ctx.policy.max_iters,
1360 last_text,
1361 tools_called,
1362 usage: total_usage,
1363 })
1364 }
1365
1366 /// Drive `Model::stream()` and assemble the result into a `ModelOutput`,
1367 /// firing `Event::ModelTokenDelta` for each text fragment along the way.
1368 ///
1369 /// Adapters that don't implement real streaming (e.g. `GeminiNative` /
1370 /// `AnthropicNative` today) fall back to the default trait impl, which
1371 /// runs `complete()` and emits the whole reply as a single delta. That
1372 /// works — the loop sees one big `ModelDelta::Text(...)` followed by
1373 /// `Stop`, fires one big `ModelTokenDelta`, and proceeds. So enabling
1374 /// `streaming` is safe regardless of which provider the user picked.
1375 async fn complete_via_stream(
1376 &self,
1377 ctx: &Context,
1378 world: &mut World,
1379 ) -> Result<ModelOutput, HarnessError> {
1380 use futures::StreamExt;
1381 let mut stream = self
1382 .model
1383 .stream(ctx)
1384 .await
1385 .map_err(harness_core::HarnessError::Model)?;
1386 let mut text = String::new();
1387 let mut reasoning = String::new();
1388 let mut usage = Usage::default();
1389 let mut stop_reason = StopReason::EndTurn;
1390 // Insertion-ordered map: index → (id, name, args). We can't use the
1391 // tool-call id as the primary key because the stream may emit args
1392 // chunks before the first chunk that carries the id; the OpenAI-compat
1393 // SSE parser already does its own buffering and surfaces `id` in
1394 // ToolCallStart, but be lenient with adapters that may interleave.
1395 let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
1396 let mut tool_order: Vec<String> = Vec::new();
1397 while let Some(item) = stream.next().await {
1398 let delta = item.map_err(harness_core::HarnessError::Model)?;
1399 match delta {
1400 ModelDelta::Text(t) => {
1401 if !t.is_empty() {
1402 self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
1403 text.push_str(&t);
1404 }
1405 }
1406 ModelDelta::ToolCallStart { id, name } => {
1407 if !tool_starts.contains_key(&id) {
1408 tool_order.push(id.clone());
1409 }
1410 tool_starts
1411 .entry(id)
1412 .or_insert_with(|| (name, String::new()));
1413 }
1414 ModelDelta::ToolCallArgs { id, partial_json } => {
1415 let entry = tool_starts
1416 .entry(id.clone())
1417 .or_insert_with(|| (String::new(), String::new()));
1418 if !tool_order.iter().any(|k| k == &id) {
1419 tool_order.push(id);
1420 }
1421 entry.1.push_str(&partial_json);
1422 }
1423 ModelDelta::ToolCallEnd { .. } => {}
1424 ModelDelta::Usage(u) => usage = u,
1425 ModelDelta::Stop(r) => stop_reason = r,
1426 ModelDelta::Reasoning(s) => {
1427 // Streamed reasoning arrives as token fragments, not lines —
1428 // concatenate verbatim (same as `text`), don't insert newlines.
1429 reasoning.push_str(&s);
1430 }
1431 // ModelDelta is `#[non_exhaustive]`; ignore future variants
1432 // we don't yet understand.
1433 _ => {}
1434 }
1435 }
1436 let tool_calls: Vec<ToolCall> = tool_order
1437 .into_iter()
1438 .filter_map(|id| {
1439 tool_starts.remove(&id).map(|(name, args)| {
1440 let args_v = serde_json::from_str::<serde_json::Value>(&args)
1441 .unwrap_or(serde_json::Value::String(args));
1442 ToolCall {
1443 id,
1444 name,
1445 args: args_v,
1446 }
1447 })
1448 })
1449 .collect();
1450 // Reconcile stop_reason with what actually came out — adapters
1451 // sometimes emit `Stop(EndTurn)` even after tool_calls, which would
1452 // confuse downstream consumers that branch on stop_reason alone.
1453 let stop_reason = if !tool_calls.is_empty() {
1454 StopReason::ToolUse
1455 } else {
1456 stop_reason
1457 };
1458 Ok(ModelOutput {
1459 text: if text.is_empty() { None } else { Some(text) },
1460 tool_calls,
1461 usage,
1462 stop_reason,
1463 reasoning: if reasoning.is_empty() {
1464 None
1465 } else {
1466 Some(reasoning)
1467 },
1468 // `ModelDelta` has no image variant: the verified image-output
1469 // path (Gemini image models over chat) answers non-streamed, so
1470 // there is nothing to accumulate here. A streaming provider that
1471 // emits images would need a `ModelDelta::Image` first.
1472 images: Vec::new(),
1473 })
1474 }
1475
1476 /// Best-effort append to the recall store. Never fails the turn.
1477 /// One tool call, under the per-call deadline. A timeout becomes an error
1478 /// *result* — the model sees it and routes around it — never a hung run.
1479 /// Errors are folded the same way: the loop's contract is that a tool call
1480 /// always produces a result turn.
1481 async fn dispatch_bounded(&self, action: &Action, world: &mut World) -> ToolResult {
1482 let fut = self.tools.dispatch(action, world);
1483 let dispatched = match self.tool_timeout {
1484 Some(deadline) => match tokio::time::timeout(deadline, fut).await {
1485 Ok(r) => r,
1486 Err(_) => {
1487 tracing::warn!(
1488 target: "harness.telemetry",
1489 event = "tool.deadline",
1490 "gen_ai.tool.name" = %action.tool,
1491 seconds = deadline.as_secs(),
1492 );
1493 return ToolResult {
1494 ok: false,
1495 content: serde_json::json!({
1496 "error": format!(
1497 "tool call exceeded its {}s deadline and was cancelled; \
1498 the operation may be too broad — narrow it or try a \
1499 different approach",
1500 deadline.as_secs()
1501 ),
1502 "timeout": true,
1503 }),
1504 trace: None,
1505 };
1506 }
1507 },
1508 None => fut.await,
1509 };
1510 dispatched.unwrap_or_else(|e| ToolResult {
1511 ok: false,
1512 content: serde_json::json!({"error": e.to_string()}),
1513 trace: None,
1514 })
1515 }
1516
1517 /// What a tool result contributes to the context: repeat suppression first,
1518 /// then the size ceiling. A repeat that is also oversized collapses to the
1519 /// pointer rather than to a truncated copy of what the model already holds.
1520 fn shape_result(
1521 &self,
1522 action: &Action,
1523 result: &ToolResult,
1524 answered: &mut std::collections::HashSet<String>,
1525 root: &std::path::Path,
1526 ) -> serde_json::Value {
1527 if !(self.tool_results.dedupe_repeats && result.ok) {
1528 return self.cap_result(action, &result.content, root);
1529 }
1530 match self.tools.risk(&action.tool) {
1531 Some(harness_core::ToolRisk::ReadOnly) => {
1532 let fp = format!("{}({})", action.tool, action.args);
1533 if answered.contains(&fp) {
1534 tracing::info!(
1535 target: "harness.telemetry",
1536 event = "tool.result.repeat",
1537 "gen_ai.tool.name" = %action.tool,
1538 );
1539 serde_json::json!({
1540 "repeat_of_earlier_call": true,
1541 "tool": action.tool,
1542 "note": "You already made this exact call in this run and nothing has \
1543 changed the workspace since. The earlier result above still \
1544 stands — use it rather than asking again.",
1545 })
1546 } else {
1547 answered.insert(fp);
1548 self.cap_result(action, &result.content, root)
1549 }
1550 }
1551 // A write invalidates every earlier read.
1552 _ => {
1553 answered.clear();
1554 self.cap_result(action, &result.content, root)
1555 }
1556 }
1557 }
1558
1559 /// Enforce [`ToolResultPolicy`] on one result before it reaches the context.
1560 ///
1561 /// Over the ceiling, the guard prefers to *spill*: full payload to a file
1562 /// inside the workspace, bounded preview plus the path inline — nothing is
1563 /// lost, and the model retrieves slices with the file tools it already has.
1564 /// Only when spilling is off (or the write fails) does it fall back to
1565 /// destructive truncation: a byte-level cut handed back as a marker object
1566 /// rather than mangled JSON, saying how much was dropped and what to do
1567 /// instead.
1568 fn cap_result(
1569 &self,
1570 action: &Action,
1571 content: &serde_json::Value,
1572 root: &std::path::Path,
1573 ) -> serde_json::Value {
1574 let Some(max) = self.tool_results.max_bytes else {
1575 return content.clone();
1576 };
1577 let serialized = content.to_string();
1578 if serialized.len() <= max {
1579 return content.clone();
1580 }
1581 if self.tool_results.spill
1582 && let Some(marker) = spill_oversized(action, content, &serialized, root)
1583 {
1584 return marker;
1585 }
1586 // Cut on a char boundary so the kept head is valid UTF-8.
1587 let mut end = max;
1588 while end > 0 && !serialized.is_char_boundary(end) {
1589 end -= 1;
1590 }
1591 tracing::warn!(
1592 target: "harness.telemetry",
1593 event = "tool.result.truncated",
1594 "gen_ai.tool.name" = %action.tool,
1595 bytes = serialized.len(),
1596 max_bytes = max,
1597 );
1598 serde_json::json!({
1599 "truncated": true,
1600 "tool": action.tool,
1601 "bytes_total": serialized.len(),
1602 "bytes_kept": end,
1603 "head": serialized[..end],
1604 "note": format!(
1605 "This result was {} bytes and was cut to {} to protect the context window. \
1606 Do not ask for it again unchanged — narrow it: request a smaller range, \
1607 a filter, or a specific field.",
1608 serialized.len(), end
1609 ),
1610 })
1611 }
1612
1613 async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
1614 if let Some(store) = &self.recall
1615 && let Err(e) = store.append(owner, session, &msg).await
1616 {
1617 tracing::warn!(error = %e, "recall append failed");
1618 }
1619 }
1620
1621 /// Best-effort post-session review. Never affects the finished run.
1622 async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
1623 let Some(cfg) = &self.learning else { return };
1624 if tools_called < cfg.nudge_interval {
1625 return;
1626 }
1627 let transcript = crate::render_transcript(&ctx.history, 12_000);
1628 let task = harness_core::Task {
1629 description: format!(
1630 "{}\n\n## Conversation transcript\n{}",
1631 cfg.review_prompt, transcript
1632 ),
1633 source: None,
1634 deadline: None,
1635 };
1636 let mut spec =
1637 crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
1638 for t in &cfg.tools {
1639 spec = spec.with_tool(t.clone());
1640 }
1641 let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
1642 // Box::pin breaks the recursive async-future cycle: AgentLoop<M> →
1643 // run_learning_review → Subagent<DynModel>::run →
1644 // AgentLoop<Arc<dyn Model>>::run_built_context. Without pinning the
1645 // compiler rejects the infinite-sized future.
1646 if let Err(e) = Box::pin(sub.run(world)).await {
1647 tracing::warn!(error = %e, "learning review failed");
1648 }
1649 }
1650
1651 /// One final model call with tools removed, asking it to write the
1652 /// best-effort conclusion from whatever it has already gathered.
1653 ///
1654 /// Errors from the model are swallowed — observability is best-effort
1655 /// here, and a transport blip during synthesis should not turn a
1656 /// near-complete run into a hard failure.
1657 async fn force_final_synthesis(
1658 &self,
1659 ctx: &mut Context,
1660 world: &mut World,
1661 total_usage: &mut harness_core::Usage,
1662 ) -> Option<String> {
1663 const SYNTHESIS_PROMPT: &str = "[system: iteration budget exhausted] \
1664 You have run out of tool-calling iterations. Write your final answer \
1665 NOW using only the tool results already in this conversation. Do not \
1666 request more tools. Mark facts you could not verify as UNKNOWN. \
1667 Include source URLs for every claim that is not UNKNOWN.";
1668
1669 // Signal to any observer (LiveProgressHook, SessionRecorder, custom
1670 // hooks) that we've used 100% of the budget and are about to force
1671 // synthesis. Pre-existing `BudgetWarning` event was unused; this is
1672 // its natural home.
1673 self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
1674
1675 // Snapshot + clear tool schemas so the model has no choice but text.
1676 let saved_tools = std::mem::take(&mut ctx.tools);
1677 ctx.history.push(Turn {
1678 role: TurnRole::User,
1679 blocks: vec![Block::Text(SYNTHESIS_PROMPT.into())],
1680 });
1681
1682 self.hooks.fire(&Event::PreModel { ctx }, world);
1683 let result = self.model.complete(ctx).await;
1684 ctx.tools = saved_tools;
1685
1686 match result {
1687 Ok(out) => {
1688 self.hooks.fire(&Event::PostModel { out: &out }, world);
1689 total_usage.input_tokens += out.usage.input_tokens;
1690 total_usage.output_tokens += out.usage.output_tokens;
1691 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1692 ctx.push_model_output(&out);
1693 out.text
1694 }
1695 Err(_) => None,
1696 }
1697 }
1698}
1699
1700/// A persistent multi-turn conversation over one [`AgentLoop`].
1701///
1702/// Holds the append-only history and, on each [`turn`](Session::turn), re-runs
1703/// the loop against a **stable prefix** (system + name-sorted tool schemas).
1704/// That byte-stable prefix is what lets a provider's prefix cache hit across
1705/// turns — the difference between paying full price to re-read the same context
1706/// every round and paying ~10% for the cached bytes (DeepSeek).
1707pub struct Session<'a, M: Model> {
1708 loop_: &'a AgentLoop<M>,
1709 history: Vec<Turn>,
1710 max_iters: u32,
1711}
1712
1713impl<'a, M: Model> Session<'a, M> {
1714 pub fn with_max_iters(mut self, n: u32) -> Self {
1715 self.max_iters = n;
1716 self
1717 }
1718 /// Preload prior turns (e.g. resumed from disk).
1719 pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
1720 self.history = seed;
1721 self
1722 }
1723 /// The accumulated conversation so far.
1724 pub fn history(&self) -> &[Turn] {
1725 &self.history
1726 }
1727 /// Start over (branch): drop the accumulated turns.
1728 pub fn reset(&mut self) {
1729 self.history.clear();
1730 }
1731
1732 /// Send one user message. Runs the ReAct loop against the accumulated
1733 /// history, then appends this user turn + the assistant reply so the next
1734 /// turn extends the same cached prefix.
1735 pub async fn turn(
1736 &mut self,
1737 message: impl Into<String>,
1738 world: &mut World,
1739 ) -> Result<Outcome, HarnessError> {
1740 let message = message.into();
1741 let task = Task {
1742 description: message.clone(),
1743 source: None,
1744 deadline: None,
1745 };
1746 let outcome = self
1747 .loop_
1748 .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
1749 .await?;
1750 let reply = match &outcome {
1751 Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
1752 Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
1753 last_text.clone().unwrap_or_default()
1754 }
1755 };
1756 self.history.push(Turn {
1757 role: TurnRole::User,
1758 blocks: vec![Block::Text(message)],
1759 });
1760 self.history.push(Turn {
1761 role: TurnRole::Assistant,
1762 blocks: vec![Block::Text(reply)],
1763 });
1764 Ok(outcome)
1765 }
1766}
1767
1768/// Audit #7: default safelist for `FixPatch::RunCommand`.
1769///
1770/// Sensors emitting `RunCommand` patches would otherwise be a silent
1771/// arbitrary-code-execution channel. We restrict the *program* by name to a
1772/// short list of well-known, side-effect-bounded formatters/fixers. Anything
1773/// else returns false and the patch is rejected (write your own `PreAutoFix`
1774/// hook returning `HookOutcome::Allow` to widen the policy).
1775///
1776/// `ReplaceFile` and `UnifiedDiff` are not restricted here — they only touch
1777/// files inside the workspace and are covered by the symlink-safe path
1778/// resolution in `harness-tools-fs`.
1779pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
1780 use harness_core::FixPatch;
1781 match patch {
1782 FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
1783 FixPatch::RunCommand { program, args, .. } => match program.as_str() {
1784 // Cargo subcommands proven side-effect-bounded.
1785 "cargo" => matches!(
1786 args.first().map(String::as_str),
1787 Some("fmt" | "clippy" | "fix"),
1788 ),
1789 "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
1790 _ => false,
1791 },
1792 // Future FixPatch variants: deny by default — review and add to the list above.
1793 _ => false,
1794 }
1795}
1796
1797/// Monotonic counter for `.harness-patch-*.diff` temp filenames — millisecond
1798/// resolution alone collides under parallel agent runs.
1799static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1800
1801/// Monotonic counter for fallback recall session ids (no `uuid` dep).
1802static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1803
1804/// Apply auto-fix patches; return short descriptions of those that succeeded.
1805///
1806/// Made `pub` (was `pub(crate)`) so integration tests can call it directly.
1807pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
1808 use harness_core::FixPatch;
1809 let mut applied = Vec::new();
1810 for p in patches {
1811 match p {
1812 FixPatch::ReplaceFile { path, content } => {
1813 let abs = world.repo.root.join(path);
1814 if let Some(parent) = abs.parent() {
1815 let _ = tokio::fs::create_dir_all(parent).await;
1816 }
1817 if tokio::fs::write(&abs, content).await.is_ok() {
1818 applied.push(format!("replaced {}", path.display()));
1819 }
1820 }
1821 FixPatch::UnifiedDiff { diff } => {
1822 if try_apply_diff(world, diff).await {
1823 applied.push("unified diff applied".into());
1824 }
1825 }
1826 FixPatch::RunCommand { program, args, cwd } => {
1827 let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
1828 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1829 if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
1830 && out.status == 0
1831 {
1832 applied.push(format!("ran `{program} {}`", args.join(" ")));
1833 }
1834 }
1835 // FixPatch is `#[non_exhaustive]`; unknown variants are skipped.
1836 _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
1837 }
1838 }
1839 applied
1840}
1841
1842/// Write `diff` to a unique temp file and try `patch -p1` first, then `-p0`.
1843/// Returns whether either succeeded. The `-p1`-then-`-p0` order matches the
1844/// reality that most agent-emitted diffs are git-style (need `-p1`) but some
1845/// hand-rolled diffs use repo-relative paths (need `-p0`).
1846async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
1847 use std::sync::atomic::Ordering;
1848 use tokio::io::AsyncWriteExt;
1849
1850 let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
1851 let pid = std::process::id();
1852 let now = world.clock.now_ms();
1853 let tmp = world
1854 .repo
1855 .root
1856 .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
1857
1858 let mut f = match tokio::fs::File::create(&tmp).await {
1859 Ok(f) => f,
1860 Err(e) => {
1861 tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
1862 return false;
1863 }
1864 };
1865 if let Err(e) = f.write_all(diff.as_bytes()).await {
1866 tracing::warn!(error=%e, "could not write patch tempfile");
1867 let _ = tokio::fs::remove_file(&tmp).await;
1868 return false;
1869 }
1870 drop(f);
1871
1872 let tmp_str = tmp.to_string_lossy().to_string();
1873 let mut applied = false;
1874 for strip in ["-p1", "-p0"] {
1875 match world
1876 .runner
1877 .exec(
1878 "patch",
1879 &[strip, "--silent", "-i", tmp_str.as_str()],
1880 Some(world.repo.root.as_path()),
1881 )
1882 .await
1883 {
1884 Ok(out) if out.status == 0 => {
1885 tracing::info!(strip, "patch applied");
1886 applied = true;
1887 break;
1888 }
1889 Ok(out) => {
1890 tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
1891 }
1892 Err(e) => {
1893 tracing::warn!(error=%e, "patch command not available");
1894 break; // patch tool missing — no point trying other strip
1895 }
1896 }
1897 }
1898 let _ = tokio::fs::remove_file(&tmp).await;
1899 applied
1900}