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