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