Skip to main content

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 learning;
12pub mod memory_layer;
13pub mod profile_guide;
14pub mod recall_layer;
15pub mod registry;
16pub mod replay;
17pub mod subagent;
18pub mod telemetry;
19
20pub use learning::*;
21pub use memory_layer::*;
22pub use profile_guide::*;
23pub use recall_layer::*;
24pub use registry::*;
25pub use replay::*;
26pub use subagent::*;
27pub use telemetry::*;
28
29use harness_compactor::{CALIBRATION_KEY, DefaultCompactor};
30use harness_core::{
31    Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
32    Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
33    StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
34};
35use harness_hooks::HookBus;
36use std::collections::HashMap;
37use std::sync::Arc;
38
39/// Governs the loop's stuck-detector. When the model repeats the *same* tool
40/// call (name + args) round after round without making progress, the loop first
41/// nudges it to change approach, then terminates cleanly with [`Outcome::Stuck`]
42/// rather than burning the rest of the budget spinning on a loop.
43///
44/// Enabled by default with conservative thresholds — the calls must be
45/// *byte-identical*, so genuine "read the same file twice" work never trips it.
46#[derive(Debug, Clone)]
47pub struct StuckPolicy {
48    pub enabled: bool,
49    /// Consecutive identical tool-call rounds before injecting a "you are
50    /// repeating yourself, change your approach" feedback signal.
51    pub nudge_after: u32,
52    /// Consecutive identical rounds before terminating with [`Outcome::Stuck`].
53    pub abort_after: u32,
54}
55
56impl Default for StuckPolicy {
57    fn default() -> Self {
58        Self {
59            enabled: true,
60            nudge_after: 3,
61            abort_after: 6,
62        }
63    }
64}
65
66/// Governs *when* and *how far* the loop compacts context. Hysteresis: only
67/// start compacting once usage crosses `high_water`, and stop as soon as it's
68/// back under `target` — instead of running every stage above a threshold on
69/// every turn. This avoids over-compacting (needlessly reaching the lossy,
70/// main-model `AutoCompact` stage) and, because compaction rewrites history and
71/// invalidates the provider prefix cache, avoids nibbling the context every
72/// single turn.
73#[derive(Debug, Clone)]
74pub struct CompactPolicy {
75    /// Start compacting when `used/window` exceeds this. Default 0.75.
76    pub high_water: f32,
77    /// Stop as soon as `used/window` is back at/under this. Default 0.55.
78    pub target: f32,
79}
80
81impl Default for CompactPolicy {
82    fn default() -> Self {
83        Self {
84            high_water: 0.75,
85            target: 0.55,
86        }
87    }
88}
89
90/// A stable fingerprint of a round's tool calls (names + args, ignoring the
91/// volatile call id). Two rounds with the same fingerprint asked for the exact
92/// same actions — the signal the stuck-detector keys on.
93fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
94    calls
95        .iter()
96        .map(|c| format!("{}({})", c.name, c.args))
97        .collect::<Vec<_>>()
98        .join("|")
99}
100
101/// Where a run finished. Each variant is `#[non_exhaustive]` so new *fields*
102/// don't break downstream matches — always include `..` when destructuring.
103#[derive(Debug, Clone)]
104pub enum Outcome {
105    /// Model returned text with no tool calls (natural end).
106    #[non_exhaustive]
107    Done {
108        text: Option<String>,
109        iters: u32,
110        tools_called: u32,
111        usage: harness_core::Usage,
112    },
113    /// Policy budget exhausted before the model stopped requesting tools.
114    /// Carries everything we know so the caller can recover partial work
115    /// (saved notes, files written by tools, the last assistant text, etc.)
116    /// instead of seeing a single bare "budget out" string.
117    #[non_exhaustive]
118    BudgetExhausted {
119        iters: u32,
120        last_text: Option<String>,
121        tools_called: u32,
122        usage: harness_core::Usage,
123    },
124    /// The agent got stuck: it repeated the *same* tool call for
125    /// `StuckPolicy::abort_after` consecutive rounds without progress, so the
126    /// loop terminated early to save the rest of the budget. Carries partial
127    /// work (last text, files already written by tools) like `BudgetExhausted`.
128    #[non_exhaustive]
129    Stuck {
130        /// Human-readable reason, e.g. "repeated `read_file(...)` 6× without progress".
131        reason: String,
132        /// How many consecutive identical rounds were observed.
133        repeated: u32,
134        iters: u32,
135        last_text: Option<String>,
136        tools_called: u32,
137        usage: harness_core::Usage,
138    },
139}
140
141/// The agent loop.
142pub struct AgentLoop<M: Model> {
143    pub model: M,
144    pub tools: ToolRegistry,
145    pub guides: Vec<Arc<dyn Guide>>,
146    pub sensors: Vec<Arc<dyn Sensor>>,
147    pub hooks: HookBus,
148    pub compactor: Arc<dyn Compactor>,
149    /// Default response format applied to every run unless overridden by
150    /// `run_typed`. See [`ResponseFormat`].
151    pub response_format: ResponseFormat,
152    /// When `true`, the loop drives each model turn via `Model::stream()`
153    /// instead of `complete()`, firing `Event::ModelTokenDelta` for each
154    /// text fragment. Tool-call deltas are still assembled inside the loop;
155    /// only the terminal `ModelOutput` shape is observable downstream.
156    pub streaming: bool,
157    /// Optional cross-session recall store. When set, the loop captures every
158    /// turn and the `session_search` tool is registered. See `with_recall`.
159    pub recall: Option<Arc<dyn harness_core::RecallStore>>,
160    /// When true (and `recall` is set), a `RecallGuide` auto-injects top-k
161    /// past context at session start.
162    pub recall_auto_inject: bool,
163    pub learning: Option<LearningConfig>,
164    /// Loop-detection policy. Enabled by default — see [`StuckPolicy`].
165    pub stuck: StuckPolicy,
166    /// Context-compaction hysteresis. See [`CompactPolicy`].
167    pub compaction: CompactPolicy,
168}
169
170impl<M: Model> AgentLoop<M> {
171    pub fn new(model: M) -> Self {
172        Self {
173            model,
174            tools: ToolRegistry::new(),
175            guides: Vec::new(),
176            sensors: Vec::new(),
177            hooks: HookBus::new(),
178            compactor: Arc::new(DefaultCompactor::new()),
179            response_format: ResponseFormat::Free,
180            streaming: false,
181            recall: None,
182            recall_auto_inject: false,
183            learning: None,
184            stuck: StuckPolicy::default(),
185            compaction: CompactPolicy::default(),
186        }
187    }
188
189    /// Override the loop-detection policy (thresholds, or disable entirely).
190    pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
191        self.stuck = policy;
192        self
193    }
194
195    /// Override the compaction hysteresis policy. See [`CompactPolicy`].
196    pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
197        self.compaction = policy;
198        self
199    }
200
201    /// Opt in to streaming the model's terminal turn token-by-token via
202    /// `Model::stream()`. Hooks subscribed to `Event::ModelTokenDelta` see
203    /// each fragment as it arrives; the rest of the loop is unchanged.
204    pub fn with_streaming(mut self, enable: bool) -> Self {
205        self.streaming = enable;
206        self
207    }
208
209    pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
210        self.compactor = c;
211        self
212    }
213
214    pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
215        self.tools.insert(t);
216        self
217    }
218
219    pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
220        self.guides.push(g);
221        self
222    }
223
224    pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
225        self.sensors.push(s);
226        self
227    }
228
229    pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
230        self.hooks.register(h);
231        self
232    }
233
234    /// Pull in every `#[hook]`-registered hook.
235    pub fn with_macro_hooks(mut self) -> Self {
236        self.hooks = self.hooks.with_macro_hooks_take();
237        self
238    }
239
240    /// Enable cross-session recall: capture every turn into `store` and
241    /// register the `session_search` tool. Owner + session id are read from
242    /// `world.profile.extra["recall_owner"|"recall_session"]` at run time.
243    pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
244        self.tools
245            .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
246        self.recall = Some(store);
247        self
248    }
249
250    /// After `with_recall`, also auto-inject top-k relevant past context at
251    /// session start (off by default — tool-only is prompt-cache friendly).
252    pub fn auto_inject(mut self) -> Self {
253        self.recall_auto_inject = true;
254        self
255    }
256
257    /// Enable the self-evolving learning loop: after a session that made
258    /// `>= cfg.nudge_interval` tool calls, fork a review subagent (white-listed to
259    /// `cfg.tools`) to update skills + memory from the transcript. Best-effort.
260    pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
261        self.learning = Some(cfg);
262        self
263    }
264
265    /// Set the default response format for all runs through this loop. See
266    /// [`ResponseFormat`]. For typed deserialisation, prefer `run_typed::<T>()`.
267    pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
268        self.response_format = fmt;
269        self
270    }
271
272    /// Shortcut for `with_response_format(ResponseFormat::JsonSchema { name, schema })`.
273    /// Accepts a raw `serde_json::Value` so callers can hand-roll the schema or
274    /// pull it from `schemars::schema_for!(T)`.
275    pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
276        self.with_response_format(ResponseFormat::JsonSchema {
277            name: name.into(),
278            schema,
279        })
280    }
281
282    pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
283        let max = harness_core::Policy::default().max_iters;
284        self.run_with_max_iters(task, world, max).await
285    }
286
287    pub async fn run_with_max_iters(
288        &self,
289        task: Task,
290        world: &mut World,
291        max_iters: u32,
292    ) -> Result<Outcome, HarnessError> {
293        self.run_with_seed_history(task, Vec::new(), world, max_iters)
294            .await
295    }
296
297    /// Run the agent and deserialise the terminal reply into `T`.
298    ///
299    /// The schema for `T` is derived via `schemars::schema_for!(T)` and
300    /// installed as `ResponseFormat::JsonSchema` for this run only — any
301    /// pre-existing `self.response_format` is ignored. On success the
302    /// returned `T` is parsed from `Outcome::Done.text` (or, on budget
303    /// exhaustion, from `Outcome::BudgetExhausted.last_text`).
304    ///
305    /// Errors:
306    /// - `HarnessError::Other` if the model returns no text at all
307    /// - `HarnessError::Other` if `serde_json::from_str::<T>(text)` fails —
308    ///   the original text is included in the message for debugging.
309    pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
310    where
311        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
312    {
313        let max = harness_core::Policy::default().max_iters;
314        self.run_typed_with_max_iters::<T>(task, world, max).await
315    }
316
317    /// Like `run_typed` but with explicit `max_iters`.
318    pub async fn run_typed_with_max_iters<T>(
319        &self,
320        task: Task,
321        world: &mut World,
322        max_iters: u32,
323    ) -> Result<T, HarnessError>
324    where
325        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
326    {
327        let schema_root = schemars::schema_for!(T);
328        let schema = serde_json::to_value(&schema_root)
329            .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
330        let name = std::any::type_name::<T>()
331            .rsplit("::")
332            .next()
333            .unwrap_or("response")
334            .to_string();
335        let fmt = ResponseFormat::JsonSchema { name, schema };
336        let outcome = self
337            .run_with_response_format(task, world, max_iters, fmt)
338            .await?;
339        let text = match outcome {
340            Outcome::Done { text: Some(t), .. }
341            | Outcome::BudgetExhausted {
342                last_text: Some(t), ..
343            }
344            | Outcome::Stuck {
345                last_text: Some(t), ..
346            } => t,
347            Outcome::Done { text: None, .. } => {
348                return Err(HarnessError::Other(
349                    "run_typed: model returned no text".into(),
350                ));
351            }
352            Outcome::Stuck {
353                last_text: None, ..
354            } => {
355                return Err(HarnessError::Other(
356                    "run_typed: agent stuck with no text".into(),
357                ));
358            }
359            Outcome::BudgetExhausted {
360                last_text: None, ..
361            } => {
362                return Err(HarnessError::Other(
363                    "run_typed: budget exhausted with no text".into(),
364                ));
365            }
366        };
367        serde_json::from_str::<T>(&text).map_err(|e| {
368            HarnessError::Other(format!(
369                "run_typed: decode {} failed: {e} — raw text was: {text}",
370                std::any::type_name::<T>()
371            ))
372        })
373    }
374
375    /// Run with a one-off `ResponseFormat` override (doesn't touch `self`).
376    pub async fn run_with_response_format(
377        &self,
378        task: Task,
379        world: &mut World,
380        max_iters: u32,
381        fmt: ResponseFormat,
382    ) -> Result<Outcome, HarnessError> {
383        // Borrow checker won't let us swap `self.response_format` because
384        // `self` is `&`. Easiest workaround: hand-roll the same setup that
385        // `run_with_seed_history` does, but with our `fmt`. We do this by
386        // calling through a private helper.
387        self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
388            .await
389    }
390
391    async fn run_with_seed_history_and_format(
392        &self,
393        task: Task,
394        seed: Vec<Turn>,
395        world: &mut World,
396        max_iters: u32,
397        fmt_override: Option<ResponseFormat>,
398    ) -> Result<Outcome, HarnessError> {
399        let mut ctx = Context::new(task);
400        ctx.policy.max_iters = max_iters;
401        ctx.tools = self.tools.schemas();
402        ctx.history = seed;
403        ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
404        self.run_built_context(ctx, world).await
405    }
406
407    /// Like `run_with_max_iters` but seeds `ctx.history` with `seed` **before**
408    /// the current user task is appended. Use this for multi-turn REPLs so
409    /// prior conversation lives in `ctx.history` (where the Compactor can see
410    /// it) instead of being concatenated into `task.description` (where it
411    /// previously bypassed compaction entirely — see audit #2).
412    pub async fn run_with_seed_history(
413        &self,
414        task: Task,
415        seed: Vec<Turn>,
416        world: &mut World,
417        max_iters: u32,
418    ) -> Result<Outcome, HarnessError> {
419        let mut ctx = Context::new(task);
420        ctx.policy.max_iters = max_iters;
421        ctx.tools = self.tools.schemas();
422        ctx.history = seed;
423        ctx.response_format = self.response_format.clone();
424        self.run_built_context(ctx, world).await
425    }
426
427    /// Start a persistent multi-turn [`Session`]. Each `turn` re-runs the loop
428    /// against the accumulated history with a **stable prefix** (system +
429    /// name-sorted tool schemas), so a provider's prefix cache (e.g. DeepSeek's,
430    /// ~10% price on cache-hit tokens) hits across turns instead of paying full
431    /// price to re-read the same bytes every round. For maximum hit rate, keep
432    /// your guides' output stable (put per-turn volatile context in the message,
433    /// not a recomputed system guide).
434    pub fn session(&self) -> Session<'_, M> {
435        Session {
436            loop_: self,
437            history: Vec::new(),
438            max_iters: harness_core::Policy::default().max_iters,
439        }
440    }
441
442    /// Inner ReAct loop on an already-prepared `Context`. Use the public
443    /// `run*` methods unless you need to inject a non-standard `Context`
444    /// (e.g. `run_with_response_format` does to apply a one-off
445    /// `ResponseFormat` without mutating `self`).
446    async fn run_built_context(
447        &self,
448        mut ctx: Context,
449        world: &mut World,
450    ) -> Result<Outcome, HarnessError> {
451        self.hooks.fire(
452            &Event::SessionStart {
453                source: SessionSource::Startup,
454            },
455            world,
456        );
457
458        // ── recall: resolve owner/session, ensure the session row ──
459        let (recall_owner, recall_session) = if self.recall.is_some() {
460            use std::sync::atomic::Ordering;
461            let owner = crate::recall_owner(world);
462            let session = world
463                .profile
464                .extra
465                .get("recall_session")
466                .and_then(|v| v.as_str())
467                .map(|s| s.to_string())
468                .unwrap_or_else(|| {
469                    format!(
470                        "sess-{}-{}",
471                        world.clock.now_ms(),
472                        RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
473                    )
474                });
475            if let Some(store) = &self.recall {
476                let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
477                if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
478                    tracing::warn!(error = %e, "recall ensure_session failed");
479                }
480            }
481            (owner, session)
482        } else {
483            (String::new(), String::new())
484        };
485
486        let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
487            if self.recall.is_none() {
488                tracing::warn!(
489                    "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
490                );
491                None
492            } else {
493                self.recall
494                    .clone()
495                    .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
496            }
497        } else {
498            None
499        };
500        let all_guides: Vec<&Arc<dyn Guide>> =
501            self.guides.iter().chain(recall_guide.iter()).collect();
502        for g in &all_guides {
503            if g.scope().matches(&ctx.task) {
504                self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
505                g.apply(&mut ctx, world).await?;
506                self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
507            }
508        }
509
510        ctx.history.push(Turn {
511            role: TurnRole::User,
512            blocks: vec![Block::Text(ctx.task.description.clone())],
513        });
514
515        if self.recall.is_some() {
516            self.recall_append(
517                &recall_owner,
518                &recall_session,
519                harness_core::RecallMessage::new(
520                    "user",
521                    ctx.task.description.clone(),
522                    world.clock.now_ms(),
523                ),
524            )
525            .await;
526        }
527
528        // Running totals — surface to caller even on BudgetExhausted.
529        let mut tools_called: u32 = 0;
530        let mut total_usage = harness_core::Usage::default();
531        let mut last_text: Option<String> = None;
532
533        // Stuck-detector state: the previous round's tool-call fingerprint and
534        // how many consecutive rounds have repeated it.
535        let mut last_fingerprint: Option<String> = None;
536        let mut repeat_count: u32 = 0;
537
538        for iter in 0..ctx.policy.max_iters {
539            self.hooks.fire(&Event::Heartbeat { iter }, world);
540
541            // Compaction with hysteresis: only once over the high-water mark,
542            // then escalate stage-by-stage, re-estimating after each, and stop
543            // the moment we're back under target. Avoids over-compacting to the
544            // lossy AutoCompact stage and avoids rewriting history (→ prefix
545            // cache miss) every turn. Token estimate is calibrated against the
546            // last real `input_tokens` via `CALIBRATION_KEY`.
547            let mut budget = self.compactor.budget(&ctx);
548            if budget.ratio() > self.compaction.high_water {
549                for stage in CompactionStage::ALL {
550                    if budget.ratio() <= self.compaction.target {
551                        break;
552                    }
553                    self.hooks.fire(&Event::PreCompact { stage }, world);
554                    self.compactor.compact(stage, &mut ctx).await?;
555                    self.hooks.fire(&Event::PostCompact { stage }, world);
556                    budget = self.compactor.budget(&ctx);
557                }
558            }
559
560            // Per-iteration guides — recall-style adapters that want to
561            // refresh their injected context every turn (e.g. MemoryGuide
562            // re-recalling against the latest user message). Default
563            // `apply_before_iter` is a no-op, so this loop is cheap for
564            // guides that don't override it.
565            for g in &all_guides {
566                if g.scope().matches(&ctx.task)
567                    && let Err(e) = g.apply_before_iter(&mut ctx, world).await
568                {
569                    tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
570                }
571            }
572
573            self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
574            let out = if self.streaming {
575                self.complete_via_stream(&ctx, world).await?
576            } else {
577                self.model.complete(&ctx).await?
578            };
579            self.hooks.fire(&Event::PostModel { out: &out }, world);
580
581            // Calibrate the compactor against ground truth: `ctx` still holds
582            // exactly what we just sent, so `budget().used` is the estimate for
583            // it. Nudge the stored correction so estimate·correction ≈ the
584            // model's real `input_tokens` next time. Self-correcting (converges
585            // even as the raw estimate drifts); clamped so one odd turn can't
586            // blow it up. This is what makes compaction fire at the right moment
587            // for *this* model + language instead of a blind char heuristic.
588            if out.usage.input_tokens > 0 {
589                let used = self.compactor.budget(&ctx).used;
590                if used > 0 {
591                    let prev = ctx
592                        .metadata
593                        .get(CALIBRATION_KEY)
594                        .and_then(|v| v.as_f64())
595                        .filter(|f| f.is_finite() && *f > 0.0)
596                        .unwrap_or(1.0);
597                    let next =
598                        (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
599                    ctx.metadata
600                        .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
601                }
602            }
603
604            // Accumulate usage even if the run later exhausts budget.
605            total_usage.input_tokens += out.usage.input_tokens;
606            total_usage.output_tokens += out.usage.output_tokens;
607            total_usage.cached_input_tokens += out.usage.cached_input_tokens;
608            if let Some(t) = &out.text {
609                last_text = Some(t.clone());
610            }
611            ctx.push_model_output(&out);
612
613            if self.recall.is_some() {
614                let calls = if out.tool_calls.is_empty() {
615                    None
616                } else {
617                    serde_json::to_string(&out.tool_calls).ok()
618                };
619                let mut m = harness_core::RecallMessage::new(
620                    "assistant",
621                    out.text.clone().unwrap_or_default(),
622                    world.clock.now_ms(),
623                );
624                m.tool_calls = calls;
625                self.recall_append(&recall_owner, &recall_session, m).await;
626            }
627
628            if out.tool_calls.is_empty() {
629                self.hooks.fire(&Event::TaskCompleted, world);
630                self.hooks.fire(&Event::SessionEnd, world);
631                self.run_learning_review(&ctx, world, tools_called).await;
632                // Thinking models (e.g. Qwen3 via Ollama) sometimes emit the
633                // whole answer into the reasoning channel and leave `text`
634                // empty. Fall back to the reasoning so the turn isn't blank.
635                let text = out
636                    .text
637                    .filter(|t| !t.trim().is_empty())
638                    .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
639                return Ok(Outcome::Done {
640                    text,
641                    iters: iter + 1,
642                    tools_called,
643                    usage: total_usage,
644                });
645            }
646
647            // ── stuck detection ─────────────────────────────────────────
648            // The model asked for tools again. If it's the *same* request as
649            // last round, it's spinning: nudge it to change tack, then abort
650            // cleanly rather than burn the rest of the budget on the loop.
651            if self.stuck.enabled {
652                let fp = tool_call_fingerprint(&out.tool_calls);
653                if last_fingerprint.as_ref() == Some(&fp) {
654                    repeat_count += 1;
655                } else {
656                    repeat_count = 1;
657                    last_fingerprint = Some(fp);
658                }
659
660                if repeat_count >= self.stuck.abort_after {
661                    let reason =
662                        format!("repeated the same tool call {repeat_count}× without progress");
663                    tracing::warn!(repeated = repeat_count, "stuck: aborting run");
664                    self.hooks.fire(&Event::SessionEnd, world);
665                    return Ok(Outcome::Stuck {
666                        reason,
667                        repeated: repeat_count,
668                        iters: iter + 1,
669                        last_text,
670                        tools_called,
671                        usage: total_usage,
672                    });
673                }
674
675                if repeat_count == self.stuck.nudge_after {
676                    tracing::warn!(
677                        repeated = repeat_count,
678                        "stuck: nudging model to change approach"
679                    );
680                    ctx.push_feedback(vec![harness_core::Signal {
681                        severity: harness_core::Severity::Warn,
682                        origin: "stuck-detector".into(),
683                        message: format!(
684                            "You have issued the same tool call {repeat_count} rounds in a row \
685                             without making progress."
686                        ),
687                        agent_hint: Some(
688                            "Stop repeating it. Inspect the actual tool result/error, try a \
689                             different approach, or give your final answer with no tool call."
690                                .into(),
691                        ),
692                        auto_fix: None,
693                        location: None,
694                    }]);
695                }
696            }
697
698            // Parallel-safe prefetch: dispatch the *leading run* of read-only
699            // tool calls concurrently (a mutating tool is a serial barrier).
700            // The sequential loop below still processes every call in order —
701            // hooks, sensors, and history stay ordered — only the dispatch IO
702            // overlaps. Reads before any write are safe; anything at/after the
703            // first mutating call runs on the normal path.
704            let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
705            {
706                let lead: Vec<&_> = out
707                    .tool_calls
708                    .iter()
709                    .take_while(|c| {
710                        self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
711                    })
712                    .collect();
713                if lead.len() > 1 {
714                    let futs =
715                        lead.iter().map(|c| {
716                            let mut w = world.clone();
717                            let action = Action {
718                                tool: c.name.clone(),
719                                call_id: c.id.clone(),
720                                args: c.args.clone(),
721                            };
722                            async move {
723                                let r = self.tools.dispatch(&action, &mut w).await.unwrap_or_else(
724                                    |e| ToolResult {
725                                        ok: false,
726                                        content: serde_json::json!({"error": e.to_string()}),
727                                        trace: None,
728                                    },
729                                );
730                                (action.call_id, r)
731                            }
732                        });
733                    for (id, r) in futures::future::join_all(futs).await {
734                        prefetched.insert(id, r);
735                    }
736                }
737            }
738
739            for call in &out.tool_calls {
740                let action = Action {
741                    tool: call.name.clone(),
742                    call_id: call.id.clone(),
743                    args: call.args.clone(),
744                };
745
746                // PreToolUse hook can deny destructive actions
747                if let HookOutcome::Deny { reason } = self
748                    .hooks
749                    .fire(&Event::PreToolUse { action: &action }, world)
750                {
751                    ctx.history.push(Turn {
752                        role: TurnRole::Tool,
753                        blocks: vec![Block::ToolResult {
754                            call_id: action.call_id.clone(),
755                            content: serde_json::json!({
756                                "ok": false,
757                                "denied_by_hook": reason,
758                            }),
759                        }],
760                    });
761                    if self.recall.is_some() {
762                        self.recall_append(
763                            &recall_owner,
764                            &recall_session,
765                            harness_core::RecallMessage::new(
766                                "tool",
767                                format!("[denied by hook] {reason}"),
768                                world.clock.now_ms(),
769                            )
770                            .with_tool_name(action.tool.clone()),
771                        )
772                        .await;
773                    }
774                    continue;
775                }
776
777                // Use the concurrently-prefetched result if we have one;
778                // otherwise dispatch now.
779                let result = if let Some(r) = prefetched.remove(&action.call_id) {
780                    r
781                } else {
782                    match self.tools.dispatch(&action, world).await {
783                        Ok(r) => r,
784                        Err(e) => ToolResult {
785                            ok: false,
786                            content: serde_json::json!({"error": e.to_string()}),
787                            trace: None,
788                        },
789                    }
790                };
791                tools_called += 1;
792                self.hooks.fire(
793                    &Event::PostToolUse {
794                        action: &action,
795                        result: &result,
796                    },
797                    world,
798                );
799
800                ctx.history.push(Turn {
801                    role: TurnRole::Tool,
802                    blocks: vec![Block::ToolResult {
803                        call_id: action.call_id.clone(),
804                        content: result.content.clone(),
805                    }],
806                });
807
808                if self.recall.is_some() {
809                    let body = serde_json::to_string(&result.content).unwrap_or_default();
810                    self.recall_append(
811                        &recall_owner,
812                        &recall_session,
813                        harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
814                            .with_tool_name(action.tool.clone()),
815                    )
816                    .await;
817                }
818
819                // run self-correct sensors
820                let mut all_signals = Vec::new();
821                for s in &self.sensors {
822                    if s.stage() != Stage::SelfCorrect {
823                        continue;
824                    }
825                    self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
826                    let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
827                        tracing::warn!(?e, "sensor failed");
828                        Vec::new()
829                    });
830                    self.hooks.fire(
831                        &Event::PostSensor {
832                            sensor: s.id(),
833                            signals: &sigs,
834                        },
835                        world,
836                    );
837                    all_signals.extend(sigs);
838                }
839                if !all_signals.is_empty() {
840                    let bundle = SignalSet::new(all_signals);
841                    let (patches, remaining) = bundle.partition_auto_fix();
842
843                    // audit #7: each patch goes through PreAutoFix.
844                    // Hooks can Deny (skip silently). Default safelist on
845                    // RunCommand catches the obvious misuses with no hook.
846                    let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
847                        if !is_default_safe_fix(p) {
848                            tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
849                            self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
850                            return false;
851                        }
852                        match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
853                            HookOutcome::Deny { reason } => {
854                                tracing::warn!(?p, %reason, "auto-fix denied by hook");
855                                self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
856                                false
857                            }
858                            _ => true,
859                        }
860                    }).collect();
861
862                    let applied = apply_patches(&approved, world).await;
863                    // Emit PostAutoFix for each approved patch with the application result.
864                    for (i, p) in approved.iter().enumerate() {
865                        self.hooks.fire(
866                            &Event::PostAutoFix {
867                                patch: p,
868                                applied: i < applied.len(),
869                            },
870                            world,
871                        );
872                    }
873                    if !applied.is_empty() {
874                        ctx.push_feedback(vec![harness_core::Signal {
875                            severity: harness_core::Severity::Hint,
876                            origin: "auto-fix".into(),
877                            message: format!(
878                                "applied {} auto-fix patch(es): {applied:?}",
879                                applied.len()
880                            ),
881                            agent_hint: Some(
882                                "re-check the affected files before continuing".into(),
883                            ),
884                            auto_fix: None,
885                            location: None,
886                        }]);
887                    }
888                    if remaining.has_blocking() {
889                        ctx.push_feedback(remaining.signals);
890                    }
891                }
892            }
893        }
894        // ── Budget exhausted ─────────────────────────────────────────
895        // Force a final synthesis pass with tools DISABLED. Otherwise the
896        // model often spins on tool calls right up to the budget cap and
897        // never emits a text conclusion, leaving the caller with nothing
898        // but `last_text` from some earlier intermediate turn (or None).
899        //
900        // The synthesis call is "free" — it costs one extra model call
901        // beyond max_iters but doesn't count toward `iters`. The result
902        // lands in `last_text` so callers display it as the answer.
903        let synthesised = self
904            .force_final_synthesis(&mut ctx, world, &mut total_usage)
905            .await;
906        if let Some(t) = synthesised {
907            last_text = Some(t);
908        }
909
910        self.hooks.fire(&Event::SessionEnd, world);
911        self.run_learning_review(&ctx, world, tools_called).await;
912        Ok(Outcome::BudgetExhausted {
913            iters: ctx.policy.max_iters,
914            last_text,
915            tools_called,
916            usage: total_usage,
917        })
918    }
919
920    /// Drive `Model::stream()` and assemble the result into a `ModelOutput`,
921    /// firing `Event::ModelTokenDelta` for each text fragment along the way.
922    ///
923    /// Adapters that don't implement real streaming (e.g. `GeminiNative` /
924    /// `AnthropicNative` today) fall back to the default trait impl, which
925    /// runs `complete()` and emits the whole reply as a single delta. That
926    /// works — the loop sees one big `ModelDelta::Text(...)` followed by
927    /// `Stop`, fires one big `ModelTokenDelta`, and proceeds. So enabling
928    /// `streaming` is safe regardless of which provider the user picked.
929    async fn complete_via_stream(
930        &self,
931        ctx: &Context,
932        world: &mut World,
933    ) -> Result<ModelOutput, HarnessError> {
934        use futures::StreamExt;
935        let mut stream = self
936            .model
937            .stream(ctx)
938            .await
939            .map_err(harness_core::HarnessError::Model)?;
940        let mut text = String::new();
941        let mut reasoning = String::new();
942        let mut usage = Usage::default();
943        let mut stop_reason = StopReason::EndTurn;
944        // Insertion-ordered map: index → (id, name, args). We can't use the
945        // tool-call id as the primary key because the stream may emit args
946        // chunks before the first chunk that carries the id; the OpenAI-compat
947        // SSE parser already does its own buffering and surfaces `id` in
948        // ToolCallStart, but be lenient with adapters that may interleave.
949        let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
950        let mut tool_order: Vec<String> = Vec::new();
951        while let Some(item) = stream.next().await {
952            let delta = item.map_err(harness_core::HarnessError::Model)?;
953            match delta {
954                ModelDelta::Text(t) => {
955                    if !t.is_empty() {
956                        self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
957                        text.push_str(&t);
958                    }
959                }
960                ModelDelta::ToolCallStart { id, name } => {
961                    if !tool_starts.contains_key(&id) {
962                        tool_order.push(id.clone());
963                    }
964                    tool_starts
965                        .entry(id)
966                        .or_insert_with(|| (name, String::new()));
967                }
968                ModelDelta::ToolCallArgs { id, partial_json } => {
969                    let entry = tool_starts
970                        .entry(id.clone())
971                        .or_insert_with(|| (String::new(), String::new()));
972                    if !tool_order.iter().any(|k| k == &id) {
973                        tool_order.push(id);
974                    }
975                    entry.1.push_str(&partial_json);
976                }
977                ModelDelta::ToolCallEnd { .. } => {}
978                ModelDelta::Usage(u) => usage = u,
979                ModelDelta::Stop(r) => stop_reason = r,
980                ModelDelta::Reasoning(s) => {
981                    // Streamed reasoning arrives as token fragments, not lines —
982                    // concatenate verbatim (same as `text`), don't insert newlines.
983                    reasoning.push_str(&s);
984                }
985                // ModelDelta is `#[non_exhaustive]`; ignore future variants
986                // we don't yet understand.
987                _ => {}
988            }
989        }
990        let tool_calls: Vec<ToolCall> = tool_order
991            .into_iter()
992            .filter_map(|id| {
993                tool_starts.remove(&id).map(|(name, args)| {
994                    let args_v = serde_json::from_str::<serde_json::Value>(&args)
995                        .unwrap_or(serde_json::Value::String(args));
996                    ToolCall {
997                        id,
998                        name,
999                        args: args_v,
1000                    }
1001                })
1002            })
1003            .collect();
1004        // Reconcile stop_reason with what actually came out — adapters
1005        // sometimes emit `Stop(EndTurn)` even after tool_calls, which would
1006        // confuse downstream consumers that branch on stop_reason alone.
1007        let stop_reason = if !tool_calls.is_empty() {
1008            StopReason::ToolUse
1009        } else {
1010            stop_reason
1011        };
1012        Ok(ModelOutput {
1013            text: if text.is_empty() { None } else { Some(text) },
1014            tool_calls,
1015            usage,
1016            stop_reason,
1017            reasoning: if reasoning.is_empty() {
1018                None
1019            } else {
1020                Some(reasoning)
1021            },
1022        })
1023    }
1024
1025    /// Best-effort append to the recall store. Never fails the turn.
1026    async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
1027        if let Some(store) = &self.recall
1028            && let Err(e) = store.append(owner, session, &msg).await
1029        {
1030            tracing::warn!(error = %e, "recall append failed");
1031        }
1032    }
1033
1034    /// Best-effort post-session review. Never affects the finished run.
1035    async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
1036        let Some(cfg) = &self.learning else { return };
1037        if tools_called < cfg.nudge_interval {
1038            return;
1039        }
1040        let transcript = crate::render_transcript(&ctx.history, 12_000);
1041        let task = harness_core::Task {
1042            description: format!(
1043                "{}\n\n## Conversation transcript\n{}",
1044                cfg.review_prompt, transcript
1045            ),
1046            source: None,
1047            deadline: None,
1048        };
1049        let mut spec =
1050            crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
1051        for t in &cfg.tools {
1052            spec = spec.with_tool(t.clone());
1053        }
1054        let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
1055        // Box::pin breaks the recursive async-future cycle: AgentLoop<M> →
1056        // run_learning_review → Subagent<DynModel>::run →
1057        // AgentLoop<Arc<dyn Model>>::run_built_context. Without pinning the
1058        // compiler rejects the infinite-sized future.
1059        if let Err(e) = Box::pin(sub.run(world)).await {
1060            tracing::warn!(error = %e, "learning review failed");
1061        }
1062    }
1063
1064    /// One final model call with tools removed, asking it to write the
1065    /// best-effort conclusion from whatever it has already gathered.
1066    ///
1067    /// Errors from the model are swallowed — observability is best-effort
1068    /// here, and a transport blip during synthesis should not turn a
1069    /// near-complete run into a hard failure.
1070    async fn force_final_synthesis(
1071        &self,
1072        ctx: &mut Context,
1073        world: &mut World,
1074        total_usage: &mut harness_core::Usage,
1075    ) -> Option<String> {
1076        const SYNTHESIS_PROMPT: &str = "[system: iteration budget exhausted] \
1077            You have run out of tool-calling iterations. Write your final answer \
1078            NOW using only the tool results already in this conversation. Do not \
1079            request more tools. Mark facts you could not verify as UNKNOWN. \
1080            Include source URLs for every claim that is not UNKNOWN.";
1081
1082        // Signal to any observer (LiveProgressHook, SessionRecorder, custom
1083        // hooks) that we've used 100% of the budget and are about to force
1084        // synthesis. Pre-existing `BudgetWarning` event was unused; this is
1085        // its natural home.
1086        self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
1087
1088        // Snapshot + clear tool schemas so the model has no choice but text.
1089        let saved_tools = std::mem::take(&mut ctx.tools);
1090        ctx.history.push(Turn {
1091            role: TurnRole::User,
1092            blocks: vec![Block::Text(SYNTHESIS_PROMPT.into())],
1093        });
1094
1095        self.hooks.fire(&Event::PreModel { ctx }, world);
1096        let result = self.model.complete(ctx).await;
1097        ctx.tools = saved_tools;
1098
1099        match result {
1100            Ok(out) => {
1101                self.hooks.fire(&Event::PostModel { out: &out }, world);
1102                total_usage.input_tokens += out.usage.input_tokens;
1103                total_usage.output_tokens += out.usage.output_tokens;
1104                total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1105                ctx.push_model_output(&out);
1106                out.text
1107            }
1108            Err(_) => None,
1109        }
1110    }
1111}
1112
1113/// A persistent multi-turn conversation over one [`AgentLoop`].
1114///
1115/// Holds the append-only history and, on each [`turn`](Session::turn), re-runs
1116/// the loop against a **stable prefix** (system + name-sorted tool schemas).
1117/// That byte-stable prefix is what lets a provider's prefix cache hit across
1118/// turns — the difference between paying full price to re-read the same context
1119/// every round and paying ~10% for the cached bytes (DeepSeek).
1120pub struct Session<'a, M: Model> {
1121    loop_: &'a AgentLoop<M>,
1122    history: Vec<Turn>,
1123    max_iters: u32,
1124}
1125
1126impl<'a, M: Model> Session<'a, M> {
1127    pub fn with_max_iters(mut self, n: u32) -> Self {
1128        self.max_iters = n;
1129        self
1130    }
1131    /// Preload prior turns (e.g. resumed from disk).
1132    pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
1133        self.history = seed;
1134        self
1135    }
1136    /// The accumulated conversation so far.
1137    pub fn history(&self) -> &[Turn] {
1138        &self.history
1139    }
1140    /// Start over (branch): drop the accumulated turns.
1141    pub fn reset(&mut self) {
1142        self.history.clear();
1143    }
1144
1145    /// Send one user message. Runs the ReAct loop against the accumulated
1146    /// history, then appends this user turn + the assistant reply so the next
1147    /// turn extends the same cached prefix.
1148    pub async fn turn(
1149        &mut self,
1150        message: impl Into<String>,
1151        world: &mut World,
1152    ) -> Result<Outcome, HarnessError> {
1153        let message = message.into();
1154        let task = Task {
1155            description: message.clone(),
1156            source: None,
1157            deadline: None,
1158        };
1159        let outcome = self
1160            .loop_
1161            .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
1162            .await?;
1163        let reply = match &outcome {
1164            Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
1165            Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
1166                last_text.clone().unwrap_or_default()
1167            }
1168        };
1169        self.history.push(Turn {
1170            role: TurnRole::User,
1171            blocks: vec![Block::Text(message)],
1172        });
1173        self.history.push(Turn {
1174            role: TurnRole::Assistant,
1175            blocks: vec![Block::Text(reply)],
1176        });
1177        Ok(outcome)
1178    }
1179}
1180
1181/// Audit #7: default safelist for `FixPatch::RunCommand`.
1182///
1183/// Sensors emitting `RunCommand` patches would otherwise be a silent
1184/// arbitrary-code-execution channel. We restrict the *program* by name to a
1185/// short list of well-known, side-effect-bounded formatters/fixers. Anything
1186/// else returns false and the patch is rejected (write your own `PreAutoFix`
1187/// hook returning `HookOutcome::Allow` to widen the policy).
1188///
1189/// `ReplaceFile` and `UnifiedDiff` are not restricted here — they only touch
1190/// files inside the workspace and are covered by the symlink-safe path
1191/// resolution in `harness-tools-fs`.
1192pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
1193    use harness_core::FixPatch;
1194    match patch {
1195        FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
1196        FixPatch::RunCommand { program, args, .. } => match program.as_str() {
1197            // Cargo subcommands proven side-effect-bounded.
1198            "cargo" => matches!(
1199                args.first().map(String::as_str),
1200                Some("fmt" | "clippy" | "fix"),
1201            ),
1202            "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
1203            _ => false,
1204        },
1205        // Future FixPatch variants: deny by default — review and add to the list above.
1206        _ => false,
1207    }
1208}
1209
1210/// Monotonic counter for `.harness-patch-*.diff` temp filenames — millisecond
1211/// resolution alone collides under parallel agent runs.
1212static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1213
1214/// Monotonic counter for fallback recall session ids (no `uuid` dep).
1215static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1216
1217/// Apply auto-fix patches; return short descriptions of those that succeeded.
1218///
1219/// Made `pub` (was `pub(crate)`) so integration tests can call it directly.
1220pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
1221    use harness_core::FixPatch;
1222    let mut applied = Vec::new();
1223    for p in patches {
1224        match p {
1225            FixPatch::ReplaceFile { path, content } => {
1226                let abs = world.repo.root.join(path);
1227                if let Some(parent) = abs.parent() {
1228                    let _ = tokio::fs::create_dir_all(parent).await;
1229                }
1230                if tokio::fs::write(&abs, content).await.is_ok() {
1231                    applied.push(format!("replaced {}", path.display()));
1232                }
1233            }
1234            FixPatch::UnifiedDiff { diff } => {
1235                if try_apply_diff(world, diff).await {
1236                    applied.push("unified diff applied".into());
1237                }
1238            }
1239            FixPatch::RunCommand { program, args, cwd } => {
1240                let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
1241                let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1242                if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
1243                    && out.status == 0
1244                {
1245                    applied.push(format!("ran `{program} {}`", args.join(" ")));
1246                }
1247            }
1248            // FixPatch is `#[non_exhaustive]`; unknown variants are skipped.
1249            _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
1250        }
1251    }
1252    applied
1253}
1254
1255/// Write `diff` to a unique temp file and try `patch -p1` first, then `-p0`.
1256/// Returns whether either succeeded. The `-p1`-then-`-p0` order matches the
1257/// reality that most agent-emitted diffs are git-style (need `-p1`) but some
1258/// hand-rolled diffs use repo-relative paths (need `-p0`).
1259async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
1260    use std::sync::atomic::Ordering;
1261    use tokio::io::AsyncWriteExt;
1262
1263    let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
1264    let pid = std::process::id();
1265    let now = world.clock.now_ms();
1266    let tmp = world
1267        .repo
1268        .root
1269        .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
1270
1271    let mut f = match tokio::fs::File::create(&tmp).await {
1272        Ok(f) => f,
1273        Err(e) => {
1274            tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
1275            return false;
1276        }
1277    };
1278    if let Err(e) = f.write_all(diff.as_bytes()).await {
1279        tracing::warn!(error=%e, "could not write patch tempfile");
1280        let _ = tokio::fs::remove_file(&tmp).await;
1281        return false;
1282    }
1283    drop(f);
1284
1285    let tmp_str = tmp.to_string_lossy().to_string();
1286    let mut applied = false;
1287    for strip in ["-p1", "-p0"] {
1288        match world
1289            .runner
1290            .exec(
1291                "patch",
1292                &[strip, "--silent", "-i", tmp_str.as_str()],
1293                Some(world.repo.root.as_path()),
1294            )
1295            .await
1296        {
1297            Ok(out) if out.status == 0 => {
1298                tracing::info!(strip, "patch applied");
1299                applied = true;
1300                break;
1301            }
1302            Ok(out) => {
1303                tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
1304            }
1305            Err(e) => {
1306                tracing::warn!(error=%e, "patch command not available");
1307                break; // patch tool missing — no point trying other strip
1308            }
1309        }
1310    }
1311    let _ = tokio::fs::remove_file(&tmp).await;
1312    applied
1313}