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