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, when `policy.max_iters` is
10//!   hit, or when a stuck-detector ([`StuckPolicy`], [`MonotonyPolicy`]) says
11//!   the run is spinning.
12
13// ── crates that used to be their own ───────────────────────────────────────
14// Ten of them, and not one carried a dependency a caller would want to avoid.
15// The boundaries bought nothing and cost every release ten more publishes and
16// every host ten more version pins; a crate boundary here has to name a
17// dependency you might not want, and these did not.
18pub mod blueprint;
19pub mod compactor;
20pub mod daemon;
21pub mod experience;
22pub mod hooks;
23pub mod loop_engine;
24pub mod orchestrator;
25pub mod sandbox;
26pub mod scheduler;
27pub mod templates;
28
29pub mod acceptance;
30pub mod goal;
31pub mod learning;
32pub mod memory_layer;
33#[cfg(feature = "otel")]
34pub mod otel;
35pub mod profile_guide;
36pub mod recall_layer;
37pub mod receipt;
38pub mod registry;
39pub mod seal;
40pub use acceptance::{Acceptance, FilesExist, NonEmptyAnswer, Verdict};
41pub use goal::{Goal, GoalStore, Phase, PhaseStatus};
42pub use receipt::{Receipt, ReceiptBuilder};
43pub use seal::{SealBreach, SealSet};
44pub mod replay;
45pub mod subagent;
46pub mod telemetry;
47
48pub use learning::*;
49pub use memory_layer::*;
50pub mod boundary_guide;
51pub use boundary_guide::*;
52pub use profile_guide::*;
53pub use recall_layer::*;
54pub use registry::*;
55pub use replay::*;
56pub use subagent::*;
57pub use telemetry::*;
58
59use crate::compactor::{CALIBRATION_KEY, DefaultCompactor};
60use crate::hooks::HookBus;
61use harness_core::{
62    Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
63    Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
64    StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
65};
66use std::collections::HashMap;
67use std::sync::Arc;
68use std::time::Duration;
69
70/// Governs the loop's stuck-detector. When the model repeats the *same* tool
71/// call (name + args) round after round without making progress, the loop first
72/// nudges it to change approach, then terminates cleanly with [`Outcome::Stuck`]
73/// rather than burning the rest of the budget spinning on a loop.
74///
75/// Enabled by default with conservative thresholds — the calls must be
76/// *byte-identical*, so genuine "read the same file twice" work never trips it.
77#[derive(Debug, Clone)]
78pub struct StuckPolicy {
79    pub enabled: bool,
80    /// Consecutive identical tool-call rounds before injecting a "you are
81    /// repeating yourself, change your approach" feedback signal.
82    pub nudge_after: u32,
83    /// Consecutive identical rounds before terminating with [`Outcome::Stuck`].
84    pub abort_after: u32,
85}
86
87impl Default for StuckPolicy {
88    fn default() -> Self {
89        Self {
90            enabled: true,
91            nudge_after: 3,
92            abort_after: 6,
93        }
94    }
95}
96
97/// A second stuck signal, orthogonal to [`StuckPolicy`]: the model calls *one*
98/// tool round after round — a different argument every time, so nothing ever
99/// repeats — and never moves on to anything else.
100///
101/// [`StuckPolicy`] cannot see this and must not be changed so that it can. It
102/// keys on *byte-identical consecutive* rounds precisely so that genuine "read
103/// the same file twice" work never trips it, and `graph_find("Xen")` followed
104/// by `graph_find("KVM")` is correctly not a repeat — the request changed. Two
105/// identical calls in a row and one tool worked for twenty rounds are different
106/// shapes, and want different thresholds and different reasons.
107///
108/// Measured on a real run: asked which platforms a product integrates with, an
109/// agent that could not retrieve the anchor entity began guessing candidates
110/// and looking them up one at a time — `graph_find("Nomad")`,
111/// `graph_find("Docker")`, `graph_find("VMware")`, `graph_find("Xen")`,
112/// `graph_find("KVM")` — twelve calls to one tool across twenty rounds, each
113/// with a different argument, none of them useful. Every individual call looked
114/// like progress. The *shape* did not, and the shape is something the loop can
115/// see without knowing anything about what `graph_find` means.
116///
117/// **Off by default, and it has to be.** The predicate is "one tool, many
118/// rounds, nothing else", and there is a large class of healthy agents whose
119/// every round is one tool: give an agent a shell and nothing else and
120/// `run_shell` is the only name it will ever emit, thirty rounds running,
121/// legitimately. No threshold rescues that — the false positive is unbounded in
122/// N, not merely rare — so a default-on version of this guard would terminate
123/// working agents, which is worse than the problem it fixes. A host that knows
124/// its agent has several tools and is meant to use them can switch it on;
125/// nobody else pays anything for its existence.
126#[derive(Debug, Clone)]
127pub struct MonotonyPolicy {
128    /// Off by default. See the type docs for why no default-on threshold is
129    /// safe for this predicate.
130    pub enabled: bool,
131    /// Consecutive single-tool rounds before injecting a "you are working one
132    /// tool and nothing else" feedback signal.
133    ///
134    /// 8, argued rather than rounded. The honest single-tool runs are surveys —
135    /// read five files, grep four paths — and a survey ends by using a
136    /// *different* tool, which resets the count; reaching eight rounds of one
137    /// name with nothing else touched is already past what surveying does. The
138    /// cost of being wrong here is one sentence of feedback, not a terminated
139    /// run, which is why the nudge can sit this close to ordinary work. This is
140    /// also the half of the policy that does the work: in the measured case a
141    /// nudge at round eight arrives with two thirds of the budget unspent.
142    pub nudge_after: u32,
143    /// Consecutive single-tool rounds before terminating with [`Outcome::Stuck`].
144    ///
145    /// 16 — twice the nudge, mirroring [`StuckPolicy`]'s own 3→6 ratio, and
146    /// 2.6× its abort so the two can never race: a byte-identical spiral is
147    /// always caught by the older policy first, under its own reason. Deliberately
148    /// generous, because this end of the policy is fatal and its only job is to
149    /// be a backstop for budgets long enough that the nudge was ignored.
150    pub abort_after: u32,
151}
152
153impl Default for MonotonyPolicy {
154    fn default() -> Self {
155        Self {
156            enabled: false,
157            nudge_after: 8,
158            abort_after: 16,
159        }
160    }
161}
162
163/// A ceiling on how much of one tool result reaches the context.
164///
165/// A single call can return more than the whole conversation: a lock file, a
166/// `SELECT *`, an MCP tool the framework does not control. Measured on a real
167/// run, "search these files for a word" cost 53,487 input tokens because one
168/// `read_file` returned a lock file — the model then paid for it on every
169/// subsequent turn, and compaction started throwing away real history to make
170/// room. A per-result ceiling is the only place to stop that: the tools cannot
171/// all be trusted (third-party MCP), and the compactor only runs after the
172/// damage is in the context.
173#[derive(Debug, Clone)]
174pub struct ToolResultPolicy {
175    /// Max serialized bytes of a single tool result. `None` disables the guard.
176    /// Default ~24 KiB — roughly 6k tokens of English, generous for a file page
177    /// or a query result, far below what blows a window.
178    pub max_bytes: Option<usize>,
179    /// Replace the payload of a read-only call that exactly repeats an earlier
180    /// one in the same run, when nothing has modified the world in between.
181    ///
182    /// [`StuckPolicy`] only sees *consecutive* identical rounds. Reading a file
183    /// at iteration 1 and again at iteration 5 is not that, and looks like
184    /// progress — but the same bytes land in the context twice and the model
185    /// learns nothing the second time. Measured on a real run, model wait was
186    /// 36.7s against 3ms of tool execution: a repeat costs context, not time,
187    /// so what is suppressed is the payload, not the call.
188    ///
189    /// Only `ToolRisk::ReadOnly` qualifies (`Network` is a separate risk, and an
190    /// external endpoint may answer differently), and any non-read-only call
191    /// clears the record — after a write, re-reading is the correct move.
192    ///
193    /// **Off by default, on the evidence.** Measured on the completion
194    /// benchmark: ceilings alone solved 6/6 tasks for 160k effective tokens;
195    /// adding repeat-suppression cut that to 32k — and lost a task. An agent
196    /// working through a file larger than one page re-reads it because it cannot
197    /// hold it, gets told it already has the answer, and does not: the content
198    /// was paged away. Five times cheaper is not worth a task a framework could
199    /// otherwise do, so this is opt-in for callers who would rather have the
200    /// tokens. (Suppressing from the *third* identical call rather than the
201    /// second would likely keep most of the saving without the failure — it
202    /// needs measuring before it becomes the default.)
203    pub dedupe_repeats: bool,
204    /// When a result exceeds `max_bytes`, save the *full* payload to a file
205    /// under `.harness/spill/` in the workspace and inline a bounded preview
206    /// plus the path, instead of cutting the tail off and throwing it away.
207    ///
208    /// Truncation destroys information: the model is told "narrow your
209    /// request", but the bytes it needed may be exactly the ones dropped, and
210    /// the only recovery is re-running the call to be truncated again.
211    /// Measured on the completion benchmark, that loop is visible as a single
212    /// task paying 184k input tokens. Spilling keeps the ceiling — the context
213    /// gets a preview, never the flood — while the whole result stays
214    /// retrievable through the file tools the agent already has (`read_file`
215    /// with offset/limit, `grep` on the spill path), because the spill lives
216    /// inside the workspace jail. Borrowed from DeepSeek Harness's `spill`
217    /// family (preview + retrieval locator), which is the same judgement.
218    ///
219    /// Costs nothing until it fires: the write happens only on the oversized
220    /// path, which the default ceiling makes rare. When the write fails (e.g.
221    /// read-only workspace) the guard falls back to plain truncation.
222    pub spill: bool,
223}
224
225impl Default for ToolResultPolicy {
226    fn default() -> Self {
227        Self {
228            max_bytes: Some(24 * 1024),
229            dedupe_repeats: false,
230            spill: true,
231        }
232    }
233}
234
235/// Governs *when* and *how far* the loop compacts context. Hysteresis: only
236/// start compacting once usage crosses `high_water`, and stop as soon as it's
237/// back under `target` — instead of running every stage above a threshold on
238/// every turn. This avoids over-compacting (needlessly reaching the lossy,
239/// main-model `AutoCompact` stage) and, because compaction rewrites history and
240/// invalidates the provider prefix cache, avoids nibbling the context every
241/// single turn.
242#[derive(Debug, Clone)]
243pub struct CompactPolicy {
244    /// Start compacting when `used/window` exceeds this. Default 0.75.
245    pub high_water: f32,
246    /// Stop as soon as `used/window` is back at/under this. Default 0.55.
247    pub target: f32,
248}
249
250impl Default for CompactPolicy {
251    fn default() -> Self {
252        Self {
253            high_water: 0.75,
254            target: 0.55,
255        }
256    }
257}
258
259/// Inline preview size for a spilled result. Deliberately smaller than the
260/// ceiling: the point of spilling is that the context gets a *glimpse* and a
261/// path, not four-fifths of the flood.
262const SPILL_PREVIEW_BYTES: usize = 4 * 1024;
263
264/// Told to the model when the step budget ran out and the loop stripped its
265/// tools. See `force_final_synthesis`.
266const BUDGET_SYNTHESIS_PROMPT: &str = "[system: budget exhausted] \
267    You have run out of budget for tool calls. Write your final answer \
268    NOW using only the tool results already in this conversation. Do not \
269    request more tools. Mark facts you could not verify as UNKNOWN. \
270    Include source URLs for every claim that is not UNKNOWN.";
271
272/// Told to the model when [`MonotonyPolicy`] stopped the run. Deliberately
273/// names the shape it was caught in: the failure mode this ends is a model
274/// guessing arguments to one tool, and such a model has usually *found*
275/// something it never got round to reporting. "Say what you could not
276/// determine" is the instruction it needs; "mark it UNKNOWN" is the same
277/// instruction the budget prompt gives for the same reason.
278const MONOTONY_SYNTHESIS_PROMPT: &str = "[system: stopped — no progress] \
279    You have called the same tool many rounds in a row without using any other \
280    tool, so no further tool calls will be granted. Write your final answer NOW \
281    using only the tool results already in this conversation. Do not request \
282    more tools. State plainly which parts you could not determine instead of \
283    guessing at them.";
284
285/// First `n` bytes of `s`, cut on a char boundary.
286fn head_of(s: &str, n: usize) -> &str {
287    let mut end = n.min(s.len());
288    while end > 0 && !s.is_char_boundary(end) {
289        end -= 1;
290    }
291    &s[..end]
292}
293
294/// The string field that dominates an oversized result, if one does.
295///
296/// Most oversized results are one big string in a small envelope — a file
297/// body, a command's stdout — and the right spill for those is the *raw text*:
298/// multi-line, so `read_file`'s line paging and `grep`'s line matching work on
299/// it. Spilling the serialized JSON instead would fold the whole payload onto
300/// one escaped line that line-oriented tools can neither page nor match
301/// (`read_file` pages by line; a 68 KB single-line file is unreachable past
302/// the first 16 KB). Returns the dotted path and the string when one field is
303/// ≥ 80% of the serialized size.
304fn dominant_string(v: &serde_json::Value, total: usize) -> Option<(String, &str)> {
305    fn walk<'a>(v: &'a serde_json::Value, path: &str, best: &mut Option<(String, &'a str)>) {
306        match v {
307            serde_json::Value::String(s) => {
308                if best.as_ref().is_none_or(|(_, b)| s.len() > b.len()) {
309                    *best = Some((path.to_string(), s.as_str()));
310                }
311            }
312            serde_json::Value::Object(m) => {
313                for (k, x) in m {
314                    let p = if path.is_empty() {
315                        k.clone()
316                    } else {
317                        format!("{path}.{k}")
318                    };
319                    walk(x, &p, best);
320                }
321            }
322            serde_json::Value::Array(a) => {
323                for (i, x) in a.iter().enumerate() {
324                    walk(x, &format!("{path}[{i}]"), best);
325                }
326            }
327            _ => {}
328        }
329    }
330    let mut best = None;
331    walk(v, "", &mut best);
332    best.filter(|(_, s)| s.len() * 5 >= total * 4)
333}
334
335/// Replace the value at a `dominant_string` dotted path with `with`.
336fn replace_at(v: &mut serde_json::Value, path: &str, with: serde_json::Value) {
337    let mut cur = v;
338    let mut rest = path;
339    loop {
340        // Next segment: `key`, `key[i]`, or a bare `[i]`.
341        let (seg, tail) = match rest.find('.') {
342            Some(dot) => (&rest[..dot], &rest[dot + 1..]),
343            None => (rest, ""),
344        };
345        let (key, idx) = match seg.find('[') {
346            Some(b) => (&seg[..b], seg[b + 1..seg.len() - 1].parse::<usize>().ok()),
347            None => (seg, None),
348        };
349        if !key.is_empty() {
350            match cur.get_mut(key) {
351                Some(next) => cur = next,
352                None => return,
353            }
354        }
355        if let Some(i) = idx {
356            match cur.get_mut(i) {
357                Some(next) => cur = next,
358                None => return,
359            }
360        }
361        if tail.is_empty() {
362            *cur = with;
363            return;
364        }
365        rest = tail;
366    }
367}
368
369/// Persist an oversized result under `.harness/spill/` in the workspace and
370/// build the inline marker. `None` on any IO failure — the caller falls back
371/// to truncation, because a guard that can error a run to protect a context
372/// window has its priorities backwards.
373fn spill_oversized(
374    action: &Action,
375    content: &serde_json::Value,
376    serialized: &str,
377    root: &std::path::Path,
378) -> Option<serde_json::Value> {
379    let dir = root.join(".harness").join("spill");
380    std::fs::create_dir_all(&dir).ok()?;
381    let id: String = action
382        .call_id
383        .chars()
384        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
385        .take(48)
386        .collect();
387    let id = if id.is_empty() { "call".into() } else { id };
388
389    let (rel, preview, meta) = match dominant_string(content, serialized.len()) {
390        // One big string in a small envelope: spill the raw text, keep the
391        // envelope inline with the big field swapped for a pointer.
392        Some((field, text)) => {
393            let rel = format!(".harness/spill/{id}-{}.txt", action.tool);
394            std::fs::write(root.join(&rel), text).ok()?;
395            let mut meta = content.clone();
396            replace_at(
397                &mut meta,
398                &field,
399                serde_json::Value::String(format!("[{} bytes spilled to {rel}]", text.len())),
400            );
401            (
402                rel,
403                head_of(text, SPILL_PREVIEW_BYTES).to_string(),
404                Some(meta),
405            )
406        }
407        // Structured payload (e.g. a long match list): spill pretty-printed,
408        // one element per line, so line-oriented retrieval still works.
409        None => {
410            let rel = format!(".harness/spill/{id}-{}.json", action.tool);
411            let pretty =
412                serde_json::to_string_pretty(content).unwrap_or_else(|_| serialized.to_string());
413            std::fs::write(root.join(&rel), &pretty).ok()?;
414            (rel, head_of(&pretty, SPILL_PREVIEW_BYTES).to_string(), None)
415        }
416    };
417    tracing::warn!(
418        target: "harness.telemetry",
419        event = "tool.result.spilled",
420        "gen_ai.tool.name" = %action.tool,
421        bytes = serialized.len(),
422        path = %rel,
423    );
424    let mut marker = serde_json::json!({
425        "spilled": true,
426        "tool": action.tool,
427        "bytes_total": serialized.len(),
428        "path": rel,
429        "preview": preview,
430        "note": format!(
431            "This result was {} bytes — too large to inline, so the FULL content was \
432             saved to '{rel}' (workspace-relative). Nothing was lost. Retrieve exactly \
433             the part you need: grep(path=\"{rel}\", pattern=...) or \
434             read_file(path=\"{rel}\", offset=..., limit=...). Do not repeat the \
435             original call — it will spill again.",
436            serialized.len()
437        ),
438    });
439    // The envelope metadata (paths, flags) rides along when it is small; a
440    // pathological envelope that is itself oversized is dropped, not inlined.
441    if let Some(meta) = meta
442        && meta.to_string().len() <= SPILL_PREVIEW_BYTES
443    {
444        marker["meta"] = meta;
445    }
446    Some(marker)
447}
448
449/// A stable fingerprint of a round's tool calls (names + args, ignoring the
450/// volatile call id). Two rounds with the same fingerprint asked for the exact
451/// same actions — the signal the stuck-detector keys on.
452fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
453    calls
454        .iter()
455        .map(|c| format!("{}({})", c.name, c.args))
456        .collect::<Vec<_>>()
457        .join("|")
458}
459
460/// The one tool a round asked for, when the whole round asked for exactly one
461/// *name*. `None` when the round mixed names — reaching for a second tool is,
462/// on its own, evidence the model is still moving, and resets
463/// [`MonotonyPolicy`]'s count.
464///
465/// Several parallel calls to the same tool count as one round of that tool:
466/// reading four files in one round is efficient work, not monotony, and the
467/// policy's threshold is in *rounds* precisely so that batching is never
468/// punished.
469fn sole_tool(calls: &[ToolCall]) -> Option<&str> {
470    let first = calls.first()?.name.as_str();
471    calls.iter().all(|c| c.name == first).then_some(first)
472}
473
474/// Cheap identity for one tool result, for the stuck detector's "did anything
475/// change" test. Not a checksum anyone depends on — only equality between two
476/// consecutive rounds of the same process matters.
477fn result_digest(tool: &str, content: &serde_json::Value) -> u64 {
478    use std::hash::{Hash, Hasher};
479    let mut h = std::collections::hash_map::DefaultHasher::new();
480    tool.hash(&mut h);
481    content.to_string().hash(&mut h);
482    h.finish()
483}
484
485/// Where a run finished. Each variant is `#[non_exhaustive]` so new *fields*
486/// don't break downstream matches — always include `..` when destructuring.
487#[derive(Debug, Clone)]
488pub enum Outcome {
489    /// Model returned text with no tool calls (natural end).
490    #[non_exhaustive]
491    Done {
492        text: Option<String>,
493        iters: u32,
494        tools_called: u32,
495        usage: harness_core::Usage,
496        /// What the acceptance checks said. `None` means nothing was asked —
497        /// so "the model stopped" is all this outcome claims.
498        verified: Option<Verdict>,
499        /// The sealed contract as it stood before the model's first turn.
500        ///
501        /// Carried out of the run because a receipt has to say *what* was
502        /// measured, not only that the measurement held. Empty when nothing
503        /// was sealed.
504        contract: crate::seal::SealSet,
505        /// Set when a sealed acceptance contract changed during the run.
506        ///
507        /// Distinct from a plain failed `verified` because the two mean
508        /// different things to a caller: a failed check is work not finished,
509        /// this is the measuring instrument having been moved. A host may want
510        /// to fail a build on the first and page someone on the second.
511        seal_breach: Option<String>,
512    },
513    /// Policy budget exhausted before the model stopped requesting tools.
514    /// Carries everything we know so the caller can recover partial work
515    /// (saved notes, files written by tools, the last assistant text, etc.)
516    /// instead of seeing a single bare "budget out" string.
517    #[non_exhaustive]
518    BudgetExhausted {
519        iters: u32,
520        last_text: Option<String>,
521        tools_called: u32,
522        usage: harness_core::Usage,
523        /// True when `Task::deadline` stopped the run rather than `max_iters`.
524        /// Both end the same way — a forced final answer — but "ran out of
525        /// time" and "ran out of steps" call for different responses from the
526        /// caller, and an unattended long run only ever hits the first.
527        deadline_reached: bool,
528    },
529    /// The agent got stuck and the loop terminated early to save the rest of
530    /// the budget. Carries partial work (last text, files already written by
531    /// tools) like `BudgetExhausted`.
532    ///
533    /// Two independent detectors end here, and `reason` says which: it repeated
534    /// the *same* tool call for `StuckPolicy::abort_after` rounds without
535    /// progress, or it worked a *single* tool for `MonotonyPolicy::abort_after`
536    /// rounds without touching anything else. Only the second forces a final
537    /// synthesis before returning — see the abort site for why.
538    #[non_exhaustive]
539    Stuck {
540        /// Human-readable reason, e.g. "repeated `read_file(...)` 6× without progress"
541        /// or "called `graph_find` for 16 consecutive rounds without using any
542        /// other tool".
543        reason: String,
544        /// How many consecutive rounds the detector counted before aborting.
545        repeated: u32,
546        iters: u32,
547        last_text: Option<String>,
548        tools_called: u32,
549        usage: harness_core::Usage,
550    },
551}
552
553/// The agent loop.
554pub struct AgentLoop<M: Model> {
555    pub model: M,
556    pub tools: ToolRegistry,
557    pub guides: Vec<Arc<dyn Guide>>,
558    pub sensors: Vec<Arc<dyn Sensor>>,
559    pub hooks: HookBus,
560    pub compactor: Arc<dyn Compactor>,
561    /// A deadline on each individual tool call. One hung call — a network tool
562    /// on a dead endpoint, a shell command waiting on stdin — otherwise takes
563    /// the whole run down with it, and the host's only recourse is a run-level
564    /// timeout that throws away every turn of finished work (measured on the
565    /// completion benchmark: a run that had already done the job was billed as
566    /// a 0-token timeout). A per-call deadline converts the hang into an error
567    /// *result* the model sees and can route around. `None` disables. The
568    /// default is generous — 120s covers a slow build — because a false
569    /// deadline on a legitimately long tool is worse than a late one.
570    pub tool_timeout: Option<Duration>,
571    /// Default response format applied to every run unless overridden by
572    /// `run_typed`. See [`ResponseFormat`].
573    pub response_format: ResponseFormat,
574    /// When `true`, the loop drives each model turn via `Model::stream()`
575    /// instead of `complete()`, firing `Event::ModelTokenDelta` for each
576    /// text fragment. Tool-call deltas are still assembled inside the loop;
577    /// only the terminal `ModelOutput` shape is observable downstream.
578    pub streaming: bool,
579    /// Optional cross-session recall store. When set, the loop captures every
580    /// turn and the `session_search` tool is registered. See `with_recall`.
581    pub recall: Option<Arc<dyn harness_core::RecallStore>>,
582    /// When true (and `recall` is set), a `RecallGuide` auto-injects top-k
583    /// past context at session start.
584    pub recall_auto_inject: bool,
585    pub learning: Option<LearningConfig>,
586    /// Loop-detection policy. Enabled by default — see [`StuckPolicy`].
587    pub stuck: StuckPolicy,
588    /// Second, independent loop-detection signal: one tool worked round after
589    /// round with nothing else touched. Off by default — see [`MonotonyPolicy`].
590    pub monotony: MonotonyPolicy,
591    /// Ceiling on context tokens, overriding the model's own window. `None`
592    /// (default) sizes the budget to `ModelInfo::context_window`.
593    pub max_input_tokens: Option<u32>,
594    /// Context-compaction hysteresis. See [`CompactPolicy`].
595    pub compaction: CompactPolicy,
596    /// Ceiling on a single tool result. See [`ToolResultPolicy`].
597    pub tool_results: ToolResultPolicy,
598    /// Conditions the run must satisfy before the loop reports success. The
599    /// model stopping is evidence it *believes* it is finished; these say
600    /// whether it is. See [`acceptance`].
601    pub acceptance: Vec<Arc<dyn Acceptance>>,
602    /// How many times a failed acceptance is handed back to the model before
603    /// the loop gives up and reports what there is. One is usually enough: a
604    /// model that ignores the first correction rarely takes the second.
605    pub acceptance_retries: u32,
606    /// System instruction injected into every run's `Context.system` (unless the
607    /// built context already carries its own). Set via [`with_system`](Self::with_system).
608    pub system: Vec<Block>,
609    /// Named auxiliary models for side tasks — compaction, memory synthesis,
610    /// judging, subagents — so the main conversation stays on one model (and
611    /// keeps its provider prompt-cache prefix intact) while cheap or specialist
612    /// work goes elsewhere. Populated via [`with_model_role`](Self::with_model_role),
613    /// read via [`model_for`](Self::model_for). An unregistered role means
614    /// "use the main model" — components fall back rather than fail.
615    pub model_roles: std::collections::HashMap<String, Arc<dyn Model>>,
616    /// True once the host installed its own compactor via
617    /// [`with_compactor`](Self::with_compactor). Guards the `"compactor"`
618    /// model-role convenience from overwriting an explicit choice.
619    pub compactor_custom: bool,
620}
621
622impl AgentLoop<harness_core::DynModel> {
623    /// Build a loop from a boxed model — what every model factory hands back
624    /// (`ApiKind::build`, a router, anything stored behind a trait object).
625    ///
626    /// `Arc<dyn Model>` deliberately does not implement `Model` (see
627    /// [`DynModel`](harness_core::DynModel) for why), so `AgentLoop::new` cannot
628    /// take one. Without this constructor every caller writes the wrapper
629    /// themselves, and the first thing a new user meets is a trait-bound error
630    /// naming a type they have never heard of.
631    ///
632    /// ```ignore
633    /// let model = ApiKind::OpenAI.build(base_url, model_id, key);
634    /// let agent = AgentLoop::boxed(model).with_tool(Arc::new(ReadFile));
635    /// ```
636    pub fn boxed(model: Arc<dyn Model>) -> Self {
637        Self::new(harness_core::DynModel(model))
638    }
639}
640
641impl<M: Model> AgentLoop<M> {
642    pub fn new(model: M) -> Self {
643        Self {
644            model,
645            tools: ToolRegistry::new(),
646            guides: Vec::new(),
647            sensors: Vec::new(),
648            hooks: HookBus::new(),
649            compactor: Arc::new(DefaultCompactor::new()),
650            tool_timeout: Some(Duration::from_secs(120)),
651            response_format: ResponseFormat::Free,
652            streaming: false,
653            recall: None,
654            recall_auto_inject: false,
655            learning: None,
656            stuck: StuckPolicy::default(),
657            monotony: MonotonyPolicy::default(),
658            max_input_tokens: None,
659            compaction: CompactPolicy::default(),
660            // On by default, because the failure it catches is invisible: a
661            // turn that produced nothing is reported as a turn that finished.
662            tool_results: ToolResultPolicy::default(),
663            acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
664            acceptance_retries: 1,
665            system: Vec::new(),
666            model_roles: std::collections::HashMap::new(),
667            compactor_custom: false,
668        }
669    }
670
671    /// Register an auxiliary model under a role name — the loop's seam for
672    /// "side tasks don't have to run on the main model".
673    ///
674    /// The main conversation stays pinned to one model, which keeps its
675    /// provider prompt-cache prefix byte-stable; compaction summaries, memory
676    /// synthesis, judging, and subagents can go to a cheaper or specialist
677    /// model instead. Components read roles via [`model_for`](Self::model_for)
678    /// and fall back to the main model when a role is unregistered.
679    ///
680    /// One role is wired automatically: registering `"compactor"` upgrades the
681    /// default structural compactor to a [`ModelBackedCompactor`] on that
682    /// model — unless the host already installed its own via
683    /// [`with_compactor`](Self::with_compactor), which always wins regardless
684    /// of call order.
685    ///
686    /// ```ignore
687    /// let agent = AgentLoop::boxed(main)
688    ///     .with_model_role("compactor", cheap.clone())
689    ///     .with_model_role("judge", strong);
690    /// ```
691    pub fn with_model_role(mut self, role: impl Into<String>, model: Arc<dyn Model>) -> Self {
692        let role = role.into();
693        if role == "compactor" && !self.compactor_custom {
694            self.compactor = Arc::new(crate::compactor::ModelBackedCompactor::new(model.clone()));
695        }
696        self.model_roles.insert(role, model);
697        self
698    }
699
700    /// Look up an auxiliary model by role. `None` means "no model registered
701    /// for this role — use the main model"; callers fall back rather than fail,
702    /// so wiring stays optional everywhere.
703    pub fn model_for(&self, role: &str) -> Option<Arc<dyn Model>> {
704        self.model_roles.get(role).cloned()
705    }
706
707    /// Set a system instruction applied to every run (into `Context.system`) —
708    /// e.g. "answer only via the governed tools; never claim you can't access
709    /// data; never invent numbers". This is the first-class seam for a system
710    /// prompt; small local models in particular need it to reliably call tools
711    /// instead of refusing or hallucinating.
712    pub fn with_system(mut self, text: impl Into<String>) -> Self {
713        self.system = vec![Block::Text(text.into())];
714        self
715    }
716
717    /// Cap context tokens below the model's own window.
718    ///
719    /// Compaction fires at a fraction of this, so it is also the only way to
720    /// exercise the compaction path without first paying for a hundred
721    /// thousand tokens of real conversation — which is how "does a long run
722    /// survive being compacted" stayed untested.
723    pub fn with_max_input_tokens(mut self, n: u32) -> Self {
724        self.max_input_tokens = Some(n);
725        self
726    }
727
728    /// Override the loop-detection policy (thresholds, or disable entirely).
729    pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
730        self.stuck = policy;
731        self
732    }
733
734    /// Opt in to (or retune) the single-tool monotony signal. Independent of
735    /// [`with_stuck_policy`](Self::with_stuck_policy): both detectors run, with
736    /// their own thresholds and their own `Outcome::Stuck` reason.
737    ///
738    /// ```ignore
739    /// let agent = AgentLoop::boxed(model)
740    ///     .with_monotony_policy(MonotonyPolicy { enabled: true, ..Default::default() });
741    /// ```
742    pub fn with_monotony_policy(mut self, policy: MonotonyPolicy) -> Self {
743        self.monotony = policy;
744        self
745    }
746
747    /// Override the compaction hysteresis policy. See [`CompactPolicy`].
748    /// Set the ceiling on a single tool result. See [`ToolResultPolicy`].
749    pub fn with_tool_result_policy(mut self, policy: ToolResultPolicy) -> Self {
750        self.tool_results = policy;
751        self
752    }
753
754    pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
755        self.compaction = policy;
756        self
757    }
758
759    /// Opt in to streaming the model's terminal turn token-by-token via
760    /// `Model::stream()`. Hooks subscribed to `Event::ModelTokenDelta` see
761    /// each fragment as it arrives; the rest of the loop is unchanged.
762    pub fn with_streaming(mut self, enable: bool) -> Self {
763        self.streaming = enable;
764        self
765    }
766
767    /// Add a condition the run must satisfy before it can report success.
768    pub fn with_acceptance(mut self, a: Arc<dyn Acceptance>) -> Self {
769        self.acceptance.push(a);
770        self
771    }
772
773    /// Replace the acceptance set outright (including the default).
774    pub fn with_acceptance_set(mut self, set: Vec<Arc<dyn Acceptance>>) -> Self {
775        self.acceptance = set;
776        self
777    }
778
779    pub fn with_acceptance_retries(mut self, n: u32) -> Self {
780        self.acceptance_retries = n;
781        self
782    }
783
784    pub fn with_tool_timeout(mut self, t: Option<Duration>) -> Self {
785        self.tool_timeout = t;
786        self
787    }
788
789    pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
790        self.compactor = c;
791        // An explicit compactor always wins over the "compactor" model-role
792        // convenience, in either call order.
793        self.compactor_custom = true;
794        self
795    }
796
797    pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
798        self.tools.insert(t);
799        self
800    }
801
802    pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
803        self.guides.push(g);
804        self
805    }
806
807    pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
808        self.sensors.push(s);
809        self
810    }
811
812    pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
813        self.hooks.register(h);
814        self
815    }
816
817    /// Pull in every `#[hook]`-registered hook.
818    pub fn with_macro_hooks(mut self) -> Self {
819        self.hooks = self.hooks.with_macro_hooks_take();
820        self
821    }
822
823    /// Enable cross-session recall: capture every turn into `store` and
824    /// register the `session_search` tool. Owner + session id are read from
825    /// `world.profile.extra["recall_owner"|"recall_session"]` at run time.
826    pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
827        self.tools
828            .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
829        self.recall = Some(store);
830        self
831    }
832
833    /// Capture every turn into `store` **without** registering a search tool.
834    ///
835    /// Ingest and retrieval are separate concerns that [`with_recall`] happens
836    /// to bundle, and the bundling is a trap: capture only ever happens when
837    /// `self.recall` is set, so a host that wants a different search tool — one
838    /// scoped per tenant, or one that copes with a language the backend's index
839    /// does not — has no way to get the writes without also getting
840    /// `session_search`, and ends up offering the model two overlapping tools
841    /// to choose between.
842    ///
843    /// Use this, then register whichever retrieval tool suits the deployment.
844    pub fn with_recall_ingest(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
845        self.recall = Some(store);
846        self
847    }
848
849    /// After `with_recall`, also auto-inject top-k relevant past context at
850    /// session start (off by default — tool-only is prompt-cache friendly).
851    pub fn auto_inject(mut self) -> Self {
852        self.recall_auto_inject = true;
853        self
854    }
855
856    /// Enable the self-evolving learning loop: after a session that made
857    /// `>= cfg.nudge_interval` tool calls, fork a review subagent (white-listed to
858    /// `cfg.tools`) to update skills + memory from the transcript. Best-effort.
859    pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
860        self.learning = Some(cfg);
861        self
862    }
863
864    /// Set the default response format for all runs through this loop. See
865    /// [`ResponseFormat`]. For typed deserialisation, prefer `run_typed::<T>()`.
866    pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
867        self.response_format = fmt;
868        self
869    }
870
871    /// Shortcut for `with_response_format(ResponseFormat::JsonSchema { name, schema })`.
872    /// Accepts a raw `serde_json::Value` so callers can hand-roll the schema or
873    /// pull it from `schemars::schema_for!(T)`.
874    pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
875        self.with_response_format(ResponseFormat::JsonSchema {
876            name: name.into(),
877            schema,
878        })
879    }
880
881    pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
882        let max = harness_core::Policy::default().max_iters;
883        self.run_with_max_iters(task, world, max).await
884    }
885
886    /// Run, and hand back the evidence alongside the result.
887    ///
888    /// The [`Receipt`] is built here rather than by the caller because the loop
889    /// already knows the two things a caller would otherwise have to restate —
890    /// the task and the model — and restating them is how a receipt ends up
891    /// describing a different run than the one that happened. `now_ms` stays a
892    /// parameter: this crate does not read the clock, so a receipt is
893    /// reproducible in a test.
894    pub async fn run_receipted(
895        &self,
896        task: Task,
897        world: &mut World,
898        now_ms: i64,
899    ) -> Result<(Outcome, Receipt), HarnessError> {
900        let description = task.description.clone();
901        let handle = self.model.info().handle;
902        let outcome = self.run(task, world).await?;
903        let receipt = ReceiptBuilder::new(description, handle, now_ms).build(&outcome);
904        Ok((outcome, receipt))
905    }
906
907    /// Advance a [`Goal`] by one phase, recording the result durably.
908    ///
909    /// Returns `None` when every phase is done — so a resume loop is
910    /// `while let Some(..) = loop_.run_goal(..).await?`.
911    ///
912    /// The phase is marked `Running` and **saved before the model starts**, so
913    /// a process that dies mid-run leaves a goal that says where it was rather
914    /// than one that looks untouched. It is saved again afterwards on both
915    /// paths. That second save on the failure path is the whole reason this
916    /// method exists: written out by hand at each call site it is four lines,
917    /// and the failure branch is the one that gets forgotten — which loses
918    /// exactly the run you most wanted a record of.
919    pub async fn run_goal(
920        &self,
921        goal: &mut Goal,
922        store: &GoalStore,
923        world: &mut World,
924        now_ms: i64,
925    ) -> Result<Option<(Outcome, Receipt)>, HarnessError> {
926        let Some(i) = goal.start_current(now_ms) else {
927            return Ok(None);
928        };
929        let _ = store.save(goal);
930
931        let task = Task {
932            description: goal.brief(),
933            source: None,
934            deadline: None,
935        };
936        let result = self.run_receipted(task, world, now_ms).await;
937
938        match &result {
939            Ok((_, receipt)) => {
940                if receipt.passed {
941                    goal.finish(i, receipt.summary(), now_ms);
942                } else {
943                    goal.fail(i, receipt.summary(), now_ms);
944                }
945            }
946            // A run that errored outright still happened, and the goal has to
947            // say so or a resume will retry it as though it were untouched.
948            Err(e) => goal.fail(i, format!("the run errored: {e}"), now_ms),
949        }
950        let _ = store.save(goal);
951
952        result.map(Some)
953    }
954
955    pub async fn run_with_max_iters(
956        &self,
957        task: Task,
958        world: &mut World,
959        max_iters: u32,
960    ) -> Result<Outcome, HarnessError> {
961        self.run_with_seed_history(task, Vec::new(), world, max_iters)
962            .await
963    }
964
965    /// Run the agent and deserialise the terminal reply into `T`.
966    ///
967    /// The schema for `T` is derived via `schemars::schema_for!(T)` and
968    /// installed as `ResponseFormat::JsonSchema` for this run only — any
969    /// pre-existing `self.response_format` is ignored. On success the
970    /// returned `T` is parsed from `Outcome::Done.text` (or, on budget
971    /// exhaustion, from `Outcome::BudgetExhausted.last_text`).
972    ///
973    /// Errors:
974    /// - `HarnessError::Other` if the model returns no text at all
975    /// - `HarnessError::Other` if `serde_json::from_str::<T>(text)` fails —
976    ///   the original text is included in the message for debugging.
977    pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
978    where
979        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
980    {
981        let max = harness_core::Policy::default().max_iters;
982        self.run_typed_with_max_iters::<T>(task, world, max).await
983    }
984
985    /// Like `run_typed` but with explicit `max_iters`.
986    pub async fn run_typed_with_max_iters<T>(
987        &self,
988        task: Task,
989        world: &mut World,
990        max_iters: u32,
991    ) -> Result<T, HarnessError>
992    where
993        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
994    {
995        let schema_root = schemars::schema_for!(T);
996        let schema = serde_json::to_value(&schema_root)
997            .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
998        let name = std::any::type_name::<T>()
999            .rsplit("::")
1000            .next()
1001            .unwrap_or("response")
1002            .to_string();
1003        let fmt = ResponseFormat::JsonSchema { name, schema };
1004        let outcome = self
1005            .run_with_response_format(task, world, max_iters, fmt)
1006            .await?;
1007        let text = match outcome {
1008            Outcome::Done { text: Some(t), .. }
1009            | Outcome::BudgetExhausted {
1010                last_text: Some(t), ..
1011            }
1012            | Outcome::Stuck {
1013                last_text: Some(t), ..
1014            } => t,
1015            Outcome::Done { text: None, .. } => {
1016                return Err(HarnessError::Other(
1017                    "run_typed: model returned no text".into(),
1018                ));
1019            }
1020            Outcome::Stuck {
1021                last_text: None, ..
1022            } => {
1023                return Err(HarnessError::Other(
1024                    "run_typed: agent stuck with no text".into(),
1025                ));
1026            }
1027            Outcome::BudgetExhausted {
1028                last_text: None, ..
1029            } => {
1030                return Err(HarnessError::Other(
1031                    "run_typed: budget exhausted with no text".into(),
1032                ));
1033            }
1034        };
1035        serde_json::from_str::<T>(&text).map_err(|e| {
1036            HarnessError::Other(format!(
1037                "run_typed: decode {} failed: {e} — raw text was: {text}",
1038                std::any::type_name::<T>()
1039            ))
1040        })
1041    }
1042
1043    /// Run with a one-off `ResponseFormat` override (doesn't touch `self`).
1044    pub async fn run_with_response_format(
1045        &self,
1046        task: Task,
1047        world: &mut World,
1048        max_iters: u32,
1049        fmt: ResponseFormat,
1050    ) -> Result<Outcome, HarnessError> {
1051        // Borrow checker won't let us swap `self.response_format` because
1052        // `self` is `&`. Easiest workaround: hand-roll the same setup that
1053        // `run_with_seed_history` does, but with our `fmt`. We do this by
1054        // calling through a private helper.
1055        self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
1056            .await
1057    }
1058
1059    async fn run_with_seed_history_and_format(
1060        &self,
1061        task: Task,
1062        seed: Vec<Turn>,
1063        world: &mut World,
1064        max_iters: u32,
1065        fmt_override: Option<ResponseFormat>,
1066    ) -> Result<Outcome, HarnessError> {
1067        let mut ctx = Context::new(task);
1068        ctx.policy.max_iters = max_iters;
1069        ctx.tools = self.tools.schemas();
1070        ctx.history = seed;
1071        ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
1072        self.run_built_context(ctx, world).await
1073    }
1074
1075    /// Like `run_with_max_iters` but seeds `ctx.history` with `seed` **before**
1076    /// the current user task is appended. Use this for multi-turn REPLs so
1077    /// prior conversation lives in `ctx.history` (where the Compactor can see
1078    /// it) instead of being concatenated into `task.description` (where it
1079    /// previously bypassed compaction entirely — see audit #2).
1080    pub async fn run_with_seed_history(
1081        &self,
1082        task: Task,
1083        seed: Vec<Turn>,
1084        world: &mut World,
1085        max_iters: u32,
1086    ) -> Result<Outcome, HarnessError> {
1087        self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
1088            .await
1089    }
1090
1091    /// Like [`run_with_seed_history`](Self::run_with_seed_history) but also seeds
1092    /// `ctx.metadata` with per-request key/values. Hooks and a
1093    /// [`ModelRouter`](harness_models::ModelRouter) read this map — e.g.
1094    /// `audit.actor` / `audit.session` for the audit trail, or
1095    /// `router.keep_local` to pin a request to the local model. This is the
1096    /// entry point a serving layer uses to pass caller identity and routing
1097    /// flags into a single, shared, reused loop.
1098    pub async fn run_with_seed_and_metadata(
1099        &self,
1100        task: Task,
1101        seed: Vec<Turn>,
1102        metadata: std::collections::BTreeMap<String, serde_json::Value>,
1103        world: &mut World,
1104        max_iters: u32,
1105    ) -> Result<Outcome, HarnessError> {
1106        let mut ctx = Context::new(task);
1107        ctx.policy.max_iters = max_iters;
1108        ctx.tools = self.tools.schemas();
1109        ctx.history = seed;
1110        ctx.metadata = metadata;
1111        ctx.response_format = self.response_format.clone();
1112        self.run_built_context(ctx, world).await
1113    }
1114
1115    /// Start a persistent multi-turn [`Session`]. Each `turn` re-runs the loop
1116    /// against the accumulated history with a **stable prefix** (system +
1117    /// name-sorted tool schemas), so a provider's prefix cache (e.g. DeepSeek's,
1118    /// ~10% price on cache-hit tokens) hits across turns instead of paying full
1119    /// price to re-read the same bytes every round. For maximum hit rate, keep
1120    /// your guides' output stable (put per-turn volatile context in the message,
1121    /// not a recomputed system guide).
1122    pub fn session(&self) -> Session<'_, M> {
1123        Session {
1124            loop_: self,
1125            history: Vec::new(),
1126            max_iters: harness_core::Policy::default().max_iters,
1127        }
1128    }
1129
1130    /// Inner ReAct loop on an already-prepared `Context`. Use the public
1131    /// `run*` methods unless you need to inject a non-standard `Context`
1132    /// (e.g. `run_with_response_format` does to apply a one-off
1133    /// `ResponseFormat` without mutating `self`).
1134    async fn run_built_context(
1135        &self,
1136        mut ctx: Context,
1137        world: &mut World,
1138    ) -> Result<Outcome, HarnessError> {
1139        if ctx.system.is_empty() && !self.system.is_empty() {
1140            ctx.system = self.system.clone();
1141        }
1142
1143        // Size the context budget to the model actually in use.
1144        //
1145        // Compaction fires at a fraction of `max_input_tokens`, so a value
1146        // unrelated to the model is wrong in one of two directions: too high and
1147        // the provider rejects the request before the compactor ever runs (a 32k
1148        // model under the 150k default would need 112,500 tokens to trigger, and
1149        // it cannot hold that many); too low and the loop discards history it
1150        // could have kept. Nothing read `ModelInfo::context_window` before this —
1151        // the framework's own defaults disagreed, 150,000 against 128,000.
1152        //
1153        // Only when the caller left the default in place; an explicit policy is
1154        // a decision and stays untouched. The output allowance is reserved,
1155        // because the window is shared between the prompt and the reply.
1156        if let Some(n) = self.max_input_tokens {
1157            // An explicit cap wins over both the default and the model window.
1158            ctx.policy.max_input_tokens = n.max(1);
1159        } else if ctx.policy.max_input_tokens == harness_core::Policy::default().max_input_tokens {
1160            let window = self.model.info().context_window;
1161            if window > 0 {
1162                // Reserve room for the reply, but never let the reservation eat
1163                // the window: an 8k model with the default 8k output allowance
1164                // would leave 0 tokens for input, and every turn — a 26-token
1165                // one included — would run all five compaction stages against a
1166                // budget of 1. Cap the reservation at a quarter of the window.
1167                let reserve = ctx.policy.max_output_tokens.min(window / 4);
1168                ctx.policy.max_input_tokens = window.saturating_sub(reserve).max(1);
1169            }
1170        }
1171
1172        self.hooks.fire(
1173            &Event::SessionStart {
1174                source: SessionSource::Startup,
1175            },
1176            world,
1177        );
1178
1179        // ── recall: resolve owner/session, ensure the session row ──
1180        let (recall_owner, recall_session) = if self.recall.is_some() {
1181            use std::sync::atomic::Ordering;
1182            let owner = crate::recall_owner(world);
1183            let session = world
1184                .profile
1185                .extra
1186                .get("recall_session")
1187                .and_then(|v| v.as_str())
1188                .map(|s| s.to_string())
1189                .unwrap_or_else(|| {
1190                    format!(
1191                        "sess-{}-{}",
1192                        world.clock.now_ms(),
1193                        RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
1194                    )
1195                });
1196            if let Some(store) = &self.recall {
1197                let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
1198                if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
1199                    tracing::warn!(error = %e, "recall ensure_session failed");
1200                }
1201            }
1202            (owner, session)
1203        } else {
1204            (String::new(), String::new())
1205        };
1206
1207        let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
1208            if self.recall.is_none() {
1209                tracing::warn!(
1210                    "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
1211                );
1212                None
1213            } else {
1214                self.recall
1215                    .clone()
1216                    .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
1217            }
1218        } else {
1219            None
1220        };
1221        let all_guides: Vec<&Arc<dyn Guide>> =
1222            self.guides.iter().chain(recall_guide.iter()).collect();
1223        for g in &all_guides {
1224            if g.scope().matches(&ctx.task) {
1225                self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
1226                g.apply(&mut ctx, world).await?;
1227                self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
1228            }
1229        }
1230
1231        ctx.history.push(Turn {
1232            role: TurnRole::User,
1233            blocks: vec![Block::Text(ctx.task.description.clone())],
1234        });
1235
1236        if self.recall.is_some() {
1237            self.recall_append(
1238                &recall_owner,
1239                &recall_session,
1240                harness_core::RecallMessage::new(
1241                    "user",
1242                    ctx.task.description.clone(),
1243                    world.clock.now_ms(),
1244                ),
1245            )
1246            .await;
1247        }
1248
1249        // Running totals — surface to caller even on BudgetExhausted.
1250        let mut tools_called: u32 = 0;
1251        let mut total_usage = harness_core::Usage::default();
1252        let mut last_text: Option<String> = None;
1253
1254        // Stuck-detector state: the previous round's tool-call fingerprint and
1255        // how many consecutive rounds have repeated it.
1256        let mut last_fingerprint: Option<String> = None;
1257        let mut repeat_count: u32 = 0;
1258        // Monotony state: the tool that has been the round's only tool, and for
1259        // how many consecutive rounds. Separate counter from `repeat_count` on
1260        // purpose — the two detectors answer different questions.
1261        let mut monotone_tool: Option<String> = None;
1262        let mut monotone_rounds: u32 = 0;
1263        // Read-only calls already answered this run, cleared whenever anything
1264        // mutates the world. See `ToolResultPolicy::dedupe_repeats`.
1265        let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
1266        // How many times the model has stopped mid-work with nothing to show.
1267        let mut acceptance_retries_left = self.acceptance_retries;
1268
1269        // The contract as it stood before the model touched anything. Taken
1270        // now, not at verdict time, because the whole point is to compare
1271        // against a state the model has not had the chance to influence.
1272        // Set once a sealed file is found to have moved; makes the failure
1273        // terminal and carries the reason out to the caller.
1274        let mut seal_breached: Option<String> = None;
1275        let sealed_before: crate::seal::SealSet = {
1276            let paths: Vec<std::path::PathBuf> =
1277                self.acceptance.iter().flat_map(|a| a.seals()).collect();
1278            if paths.is_empty() {
1279                crate::seal::SealSet::default()
1280            } else {
1281                crate::seal::SealSet::capture(&world.repo.root, paths)
1282            }
1283        };
1284
1285        // How far the loop actually got. Equal to `max_iters` on a normal
1286        // budget exhaustion, less when the wall clock stopped it first.
1287        let mut iters_done = ctx.policy.max_iters;
1288        let mut deadline_reached = false;
1289
1290        for iter in 0..ctx.policy.max_iters {
1291            // ── wall-clock budget ───────────────────────────────────────
1292            // `max_iters` bounds *steps*, which says nothing about elapsed
1293            // time: one iteration can be a 100ms read or a 20-minute build.
1294            // An unattended run is bounded in hours, not rounds, so a task
1295            // may carry a deadline — and reaching it goes through the same
1296            // forced-synthesis exit as a spent step budget, so the caller
1297            // gets the conclusion of the work rather than a dropped future.
1298            if let Some(at) = ctx.task.deadline
1299                && world.clock.now_ms() >= at
1300            {
1301                tracing::warn!(
1302                    iter,
1303                    deadline = at,
1304                    "task deadline reached — forcing a final answer"
1305                );
1306                iters_done = iter;
1307                deadline_reached = true;
1308                break;
1309            }
1310            self.hooks.fire(&Event::Heartbeat { iter }, world);
1311
1312            // Compaction with hysteresis: only once over the high-water mark,
1313            // then escalate stage-by-stage, re-estimating after each, and stop
1314            // the moment we're back under target. Avoids over-compacting to the
1315            // lossy AutoCompact stage and avoids rewriting history (→ prefix
1316            // cache miss) every turn. Token estimate is calibrated against the
1317            // last real `input_tokens` via `CALIBRATION_KEY`.
1318            let mut budget = self.compactor.budget(&ctx);
1319            if budget.ratio() > self.compaction.high_water {
1320                for stage in CompactionStage::ALL {
1321                    if budget.ratio() <= self.compaction.target {
1322                        break;
1323                    }
1324                    self.hooks.fire(&Event::PreCompact { stage }, world);
1325                    let before = budget.used;
1326                    let turns_before = ctx.history.len();
1327                    self.compactor.compact(stage, &mut ctx).await?;
1328                    budget = self.compactor.budget(&ctx);
1329                    // Compaction used to leave no trace anywhere: not in the
1330                    // log, not in the transcript. That is a bad property for
1331                    // the one mechanism that rewrites the conversation, and it
1332                    // is how a compaction bug that killed long runs stayed
1333                    // invisible — the only symptom was a provider error with
1334                    // no hint of what had touched the history.
1335                    tracing::info!(
1336                        stage = ?stage,
1337                        tokens_before = before,
1338                        tokens_after = budget.used,
1339                        turns_before,
1340                        turns_after = ctx.history.len(),
1341                        "compacted context"
1342                    );
1343                    self.hooks.fire(
1344                        &Event::PostCompact {
1345                            stage,
1346                            before,
1347                            after: budget.used,
1348                        },
1349                        world,
1350                    );
1351                }
1352            }
1353
1354            // Per-iteration guides — recall-style adapters that want to
1355            // refresh their injected context every turn (e.g. MemoryGuide
1356            // re-recalling against the latest user message). Default
1357            // `apply_before_iter` is a no-op, so this loop is cheap for
1358            // guides that don't override it.
1359            for g in &all_guides {
1360                if g.scope().matches(&ctx.task)
1361                    && let Err(e) = g.apply_before_iter(&mut ctx, world).await
1362                {
1363                    tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
1364                }
1365            }
1366
1367            self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
1368            let out = if self.streaming {
1369                self.complete_via_stream(&ctx, world).await?
1370            } else {
1371                self.model.complete(&ctx).await?
1372            };
1373            self.hooks.fire(&Event::PostModel { out: &out }, world);
1374
1375            // Calibrate the compactor against ground truth: `ctx` still holds
1376            // exactly what we just sent, so `budget().used` is the estimate for
1377            // it. Nudge the stored correction so estimate·correction ≈ the
1378            // model's real `input_tokens` next time. Self-correcting (converges
1379            // even as the raw estimate drifts); clamped so one odd turn can't
1380            // blow it up. This is what makes compaction fire at the right moment
1381            // for *this* model + language instead of a blind char heuristic.
1382            if out.usage.input_tokens > 0 {
1383                let used = self.compactor.budget(&ctx).used;
1384                if used > 0 {
1385                    let prev = ctx
1386                        .metadata
1387                        .get(CALIBRATION_KEY)
1388                        .and_then(|v| v.as_f64())
1389                        .filter(|f| f.is_finite() && *f > 0.0)
1390                        .unwrap_or(1.0);
1391                    let next =
1392                        (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
1393                    ctx.metadata
1394                        .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
1395                }
1396            }
1397
1398            // Accumulate usage even if the run later exhausts budget.
1399            total_usage.input_tokens += out.usage.input_tokens;
1400            total_usage.output_tokens += out.usage.output_tokens;
1401            total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1402            total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
1403            if let Some(t) = &out.text {
1404                last_text = Some(t.clone());
1405            }
1406            ctx.push_model_output(&out);
1407
1408            if self.recall.is_some() {
1409                let calls = if out.tool_calls.is_empty() {
1410                    None
1411                } else {
1412                    serde_json::to_string(&out.tool_calls).ok()
1413                };
1414                let mut m = harness_core::RecallMessage::new(
1415                    "assistant",
1416                    out.text.clone().unwrap_or_default(),
1417                    world.clock.now_ms(),
1418                );
1419                m.tool_calls = calls;
1420                self.recall_append(&recall_owner, &recall_session, m).await;
1421            }
1422
1423            if out.tool_calls.is_empty() {
1424                // The model stopping is its opinion that the work is done.
1425                // Before taking it as fact, run whatever the caller said
1426                // "done" actually means. A failure goes back as an instruction
1427                // and the loop carries on; the retry cap keeps a model that
1428                // ignores corrections from eating the budget.
1429                let mut verdict: Option<Verdict> = None;
1430                if !self.acceptance.is_empty() {
1431                    // Record this turn first — the checks read the transcript.
1432                    let mut probe = ctx.clone();
1433                    probe.history.push(Turn {
1434                        role: TurnRole::Assistant,
1435                        blocks: out
1436                            .text
1437                            .as_deref()
1438                            .filter(|t| !t.trim().is_empty())
1439                            .map(|t| vec![Block::Text(t.to_string())])
1440                            .unwrap_or_default(),
1441                    });
1442                    for check in &self.acceptance {
1443                        let v = check.check(&probe, world).await;
1444                        self.hooks.fire(
1445                            &Event::AcceptanceChecked {
1446                                name: check.name(),
1447                                passed: v.passed,
1448                                reason: &v.reason,
1449                            },
1450                            world,
1451                        );
1452                        if !v.passed {
1453                            tracing::info!(
1454                                check = check.name(),
1455                                reason = %v.reason,
1456                                "acceptance failed"
1457                            );
1458                            verdict = Some(v);
1459                            break;
1460                        }
1461                    }
1462                    // Everything passed. Say so explicitly: "checked, and it
1463                    // holds up" is a different claim from "nobody looked", and
1464                    // the host has to be able to tell them apart.
1465                    verdict = verdict.or_else(|| Some(Verdict::passed()));
1466                }
1467
1468                // A pass is only worth the contract it was measured against.
1469                // Re-read the sealed files and refuse if any moved.
1470                if !sealed_before.is_empty() && verdict.as_ref().is_some_and(|v| v.passed) {
1471                    let paths: Vec<std::path::PathBuf> =
1472                        self.acceptance.iter().flat_map(|a| a.seals()).collect();
1473                    let now = crate::seal::SealSet::capture(&world.repo.root, paths);
1474                    let breaches = sealed_before.breaches(&now);
1475                    if !breaches.is_empty() {
1476                        let what = breaches
1477                            .iter()
1478                            .map(|b| b.describe())
1479                            .collect::<Vec<_>>()
1480                            .join("; ");
1481                        tracing::error!(
1482                            breaches = %what,
1483                            "acceptance contract changed during the run — refusing the pass"
1484                        );
1485                        self.hooks
1486                            .fire(&Event::SealBreached { detail: &what }, world);
1487                        // Deliberately NOT a retry. Every other acceptance
1488                        // failure is handed back as an instruction because the
1489                        // model can act on it; this one would be handing back
1490                        // "you edited the gate", to the party that edited it,
1491                        // with the file still writable. The run is over.
1492                        seal_breached = Some(what);
1493                        verdict = Some(Verdict::failed(
1494                            "the acceptance contract was modified during this run",
1495                        ));
1496                    }
1497                }
1498
1499                if let Some(v) = verdict.clone().filter(|v| !v.passed)
1500                    && seal_breached.is_none()
1501                    && acceptance_retries_left > 0
1502                    && iter + 1 < ctx.policy.max_iters
1503                {
1504                    acceptance_retries_left -= 1;
1505                    ctx.history.push(Turn {
1506                        role: TurnRole::User,
1507                        blocks: vec![Block::Text(v.reason)],
1508                    });
1509                    continue;
1510                }
1511
1512                self.hooks.fire(&Event::TaskCompleted, world);
1513                self.hooks.fire(&Event::SessionEnd, world);
1514                self.run_learning_review(&ctx, world, tools_called).await;
1515                // Thinking models (e.g. Qwen3 via Ollama) sometimes emit the
1516                // whole answer into the reasoning channel and leave `text`
1517                // empty. Fall back to the reasoning so the turn isn't blank —
1518                // but `verified` says whether anyone agreed it was done.
1519                let text = out
1520                    .text
1521                    .filter(|t| !t.trim().is_empty())
1522                    .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
1523                return Ok(Outcome::Done {
1524                    text,
1525                    iters: iter + 1,
1526                    tools_called,
1527                    usage: total_usage,
1528                    verified: verdict,
1529                    contract: sealed_before.clone(),
1530                    seal_breach: seal_breached,
1531                });
1532            }
1533
1534            // Stuck detection runs *after* dispatch — see the block below the
1535            // tool loop, which needs this round's results to judge progress.
1536
1537            // Parallel-safe prefetch: dispatch the *leading run* of read-only
1538            // tool calls concurrently (a mutating tool is a serial barrier).
1539            // The sequential loop below still processes every call in order —
1540            // hooks, sensors, and history stay ordered — only the dispatch IO
1541            // overlaps. Reads before any write are safe; anything at/after the
1542            // first mutating call runs on the normal path.
1543            // Digest of what each tool handed back this round, for the stuck
1544            // check below. Digests rather than the payloads: results are
1545            // already capped, but a per-round copy of every one of them is
1546            // still a cost the detector does not need to pay.
1547            let mut round_results: Vec<u64> = Vec::new();
1548
1549            let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
1550            {
1551                let lead: Vec<&_> = out
1552                    .tool_calls
1553                    .iter()
1554                    .take_while(|c| {
1555                        self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
1556                    })
1557                    .collect();
1558                if lead.len() > 1 {
1559                    let futs = lead.iter().map(|c| {
1560                        let mut w = world.clone();
1561                        let action = Action {
1562                            tool: c.name.clone(),
1563                            call_id: c.id.clone(),
1564                            args: c.args.clone(),
1565                        };
1566                        async move {
1567                            let r = self.dispatch_bounded(&action, &mut w).await;
1568                            (action.call_id, r)
1569                        }
1570                    });
1571                    for (id, r) in futures::future::join_all(futs).await {
1572                        prefetched.insert(id, r);
1573                    }
1574                }
1575            }
1576
1577            for call in &out.tool_calls {
1578                let action = Action {
1579                    tool: call.name.clone(),
1580                    call_id: call.id.clone(),
1581                    args: call.args.clone(),
1582                };
1583
1584                // PreToolUse hook can deny destructive actions
1585                if let HookOutcome::Deny { reason } = self
1586                    .hooks
1587                    .fire(&Event::PreToolUse { action: &action }, world)
1588                {
1589                    ctx.history.push(Turn {
1590                        role: TurnRole::Tool,
1591                        blocks: vec![Block::ToolResult {
1592                            call_id: action.call_id.clone(),
1593                            content: serde_json::json!({
1594                                "ok": false,
1595                                "denied_by_hook": reason,
1596                            }),
1597                        }],
1598                    });
1599                    if self.recall.is_some() {
1600                        self.recall_append(
1601                            &recall_owner,
1602                            &recall_session,
1603                            harness_core::RecallMessage::new(
1604                                "tool",
1605                                format!("[denied by hook] {reason}"),
1606                                world.clock.now_ms(),
1607                            )
1608                            .with_tool_name(action.tool.clone()),
1609                        )
1610                        .await;
1611                    }
1612                    continue;
1613                }
1614
1615                // Use the concurrently-prefetched result if we have one;
1616                // otherwise dispatch now.
1617                let result = if let Some(r) = prefetched.remove(&action.call_id) {
1618                    r
1619                } else {
1620                    self.dispatch_bounded(&action, world).await
1621                };
1622                tools_called += 1;
1623
1624                // Decide the final payload *before* announcing the result, so
1625                // hooks, telemetry and the context all describe the same thing:
1626                // an audit that logs a 200 KB blob the model never saw is not an
1627                // audit of what happened.
1628                let result = ToolResult {
1629                    content: self.shape_result(&action, &result, &mut answered, &world.repo.root),
1630                    ..result
1631                };
1632                self.hooks.fire(
1633                    &Event::PostToolUse {
1634                        action: &action,
1635                        result: &result,
1636                    },
1637                    world,
1638                );
1639
1640                round_results.push(result_digest(&action.tool, &result.content));
1641
1642                ctx.history.push(Turn {
1643                    role: TurnRole::Tool,
1644                    blocks: vec![Block::ToolResult {
1645                        call_id: action.call_id.clone(),
1646                        content: result.content.clone(),
1647                    }],
1648                });
1649
1650                if self.recall.is_some() {
1651                    let body = serde_json::to_string(&result.content).unwrap_or_default();
1652                    self.recall_append(
1653                        &recall_owner,
1654                        &recall_session,
1655                        harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
1656                            .with_tool_name(action.tool.clone()),
1657                    )
1658                    .await;
1659                }
1660
1661                // run self-correct sensors
1662                let mut all_signals = Vec::new();
1663                for s in &self.sensors {
1664                    if s.stage() != Stage::SelfCorrect {
1665                        continue;
1666                    }
1667                    self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
1668                    let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
1669                        tracing::warn!(?e, "sensor failed");
1670                        Vec::new()
1671                    });
1672                    self.hooks.fire(
1673                        &Event::PostSensor {
1674                            sensor: s.id(),
1675                            signals: &sigs,
1676                        },
1677                        world,
1678                    );
1679                    all_signals.extend(sigs);
1680                }
1681                if !all_signals.is_empty() {
1682                    let bundle = SignalSet::new(all_signals);
1683                    let (patches, remaining) = bundle.partition_auto_fix();
1684
1685                    // audit #7: each patch goes through PreAutoFix.
1686                    // Hooks can Deny (skip silently). Default safelist on
1687                    // RunCommand catches the obvious misuses with no hook.
1688                    let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
1689                        if !is_default_safe_fix(p) {
1690                            tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
1691                            self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1692                            return false;
1693                        }
1694                        match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
1695                            HookOutcome::Deny { reason } => {
1696                                tracing::warn!(?p, %reason, "auto-fix denied by hook");
1697                                self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1698                                false
1699                            }
1700                            _ => true,
1701                        }
1702                    }).collect();
1703
1704                    let applied = apply_patches(&approved, world).await;
1705                    // Emit PostAutoFix for each approved patch with the application result.
1706                    for (i, p) in approved.iter().enumerate() {
1707                        self.hooks.fire(
1708                            &Event::PostAutoFix {
1709                                patch: p,
1710                                applied: i < applied.len(),
1711                            },
1712                            world,
1713                        );
1714                    }
1715                    if !applied.is_empty() {
1716                        ctx.push_feedback(vec![harness_core::Signal {
1717                            severity: harness_core::Severity::Hint,
1718                            origin: "auto-fix".into(),
1719                            message: format!(
1720                                "applied {} auto-fix patch(es): {applied:?}",
1721                                applied.len()
1722                            ),
1723                            agent_hint: Some(
1724                                "re-check the affected files before continuing".into(),
1725                            ),
1726                            auto_fix: None,
1727                            location: None,
1728                        }]);
1729                    }
1730                    if remaining.has_blocking() {
1731                        ctx.push_feedback(remaining.signals);
1732                    }
1733                }
1734            }
1735
1736            // ── stuck detection ─────────────────────────────────────────
1737            // A round repeats when the request AND what came back were both
1738            // identical. The same call whose *result moved* is the model
1739            // watching something progress — polling a build, a background
1740            // job, a queue — not spinning, which is why this runs after
1741            // dispatch: before it, this round's results do not exist yet and
1742            // the check could only compare the request. Judging on the
1743            // request alone aborted runs that were making progress, and a
1744            // long unattended task is mostly made of such waits.
1745            //
1746            // A poll whose result never changes still counts, and still
1747            // aborts: burning model calls on a thing that is not moving is
1748            // exactly what this guard is for. What bounds a *legitimate*
1749            // long wait is `max_iters`, not this.
1750            if self.stuck.enabled {
1751                let fp = format!(
1752                    "{}#{}",
1753                    tool_call_fingerprint(&out.tool_calls),
1754                    round_results
1755                        .iter()
1756                        .map(u64::to_string)
1757                        .collect::<Vec<_>>()
1758                        .join(",")
1759                );
1760                if last_fingerprint.as_ref() == Some(&fp) {
1761                    repeat_count += 1;
1762                } else {
1763                    repeat_count = 1;
1764                    last_fingerprint = Some(fp);
1765                }
1766
1767                if repeat_count >= self.stuck.abort_after {
1768                    let reason = format!(
1769                        "repeated the same tool call {repeat_count}× with an unchanging result"
1770                    );
1771                    tracing::warn!(repeated = repeat_count, "stuck: aborting run");
1772                    self.hooks.fire(&Event::SessionEnd, world);
1773                    return Ok(Outcome::Stuck {
1774                        reason,
1775                        repeated: repeat_count,
1776                        iters: iter + 1,
1777                        last_text,
1778                        tools_called,
1779                        usage: total_usage,
1780                    });
1781                }
1782
1783                if repeat_count == self.stuck.nudge_after {
1784                    tracing::warn!(
1785                        repeated = repeat_count,
1786                        "stuck: nudging model to change approach"
1787                    );
1788                    ctx.push_feedback(vec![harness_core::Signal {
1789                        severity: harness_core::Severity::Warn,
1790                        origin: "stuck-detector".into(),
1791                        message: format!(
1792                            "You have issued the same tool call {repeat_count} rounds in a row \
1793                             and the result has not changed."
1794                        ),
1795                        agent_hint: Some(
1796                            "Stop repeating it. Inspect the actual tool result/error, try a \
1797                             different approach, or give your final answer with no tool call. \
1798                             If you are waiting on something, wait inside one call rather than \
1799                             polling in a tight loop."
1800                                .into(),
1801                        ),
1802                        auto_fix: None,
1803                        location: None,
1804                    }]);
1805                }
1806            }
1807
1808            // ── monotony detection ──────────────────────────────────────
1809            // The second signal, and deliberately not part of the block above:
1810            // that one asks "did this round repeat?", this one asks "has the
1811            // model been working a single tool and nothing else?". A guessing
1812            // spiral answers no to the first and yes to the second, which is
1813            // why the byte-identical policy stays exactly as strict as it is.
1814            //
1815            // Off unless the host asked for it — see `MonotonyPolicy` for why
1816            // no threshold is safe by default. Independent of `stuck.enabled`,
1817            // so a host can run either, both, or neither.
1818            if self.monotony.enabled {
1819                match sole_tool(&out.tool_calls) {
1820                    // Same single tool as last round: the run continues.
1821                    Some(name) if monotone_tool.as_deref() == Some(name) => monotone_rounds += 1,
1822                    // A different single tool: a new run of one.
1823                    Some(name) => {
1824                        monotone_tool = Some(name.to_string());
1825                        monotone_rounds = 1;
1826                    }
1827                    // A mixed round. Reaching for a second tool is movement,
1828                    // and movement is what this detector is looking for.
1829                    None => {
1830                        monotone_tool = None;
1831                        monotone_rounds = 0;
1832                    }
1833                }
1834
1835                if monotone_rounds >= self.monotony.abort_after {
1836                    let name = monotone_tool.clone().unwrap_or_default();
1837                    let reason = format!(
1838                        "called `{name}` for {monotone_rounds} consecutive rounds \
1839                         without using any other tool"
1840                    );
1841                    tracing::warn!(tool = %name, rounds = monotone_rounds, "monotony: aborting run");
1842
1843                    // Force the final answer before returning. A model caught
1844                    // here is by definition one that will not stop calling
1845                    // tools on its own, so terminating it without asking for
1846                    // text hands the caller the same empty partial answer the
1847                    // detector exists to prevent — earlier, and therefore
1848                    // worse. Stripping the tools and asking once is the exit
1849                    // the model could not find; it costs one model call and no
1850                    // prompt bytes at all. (The budget-exhausted path has done
1851                    // this since it existed; this is the same exit, reached
1852                    // sooner.)
1853                    if let Some(t) = self
1854                        .force_final_synthesis(
1855                            &mut ctx,
1856                            world,
1857                            &mut total_usage,
1858                            MONOTONY_SYNTHESIS_PROMPT,
1859                        )
1860                        .await
1861                    {
1862                        last_text = Some(t);
1863                    }
1864
1865                    self.hooks.fire(&Event::SessionEnd, world);
1866                    return Ok(Outcome::Stuck {
1867                        reason,
1868                        repeated: monotone_rounds,
1869                        iters: iter + 1,
1870                        last_text,
1871                        tools_called,
1872                        usage: total_usage,
1873                    });
1874                }
1875
1876                if monotone_rounds == self.monotony.nudge_after {
1877                    let name = monotone_tool.clone().unwrap_or_default();
1878                    tracing::warn!(
1879                        tool = %name,
1880                        rounds = monotone_rounds,
1881                        "monotony: nudging model to change approach"
1882                    );
1883                    ctx.push_feedback(vec![harness_core::Signal {
1884                        severity: harness_core::Severity::Warn,
1885                        origin: "monotony-detector".into(),
1886                        message: format!(
1887                            "You have called `{name}` for {monotone_rounds} rounds in a row \
1888                             and used no other tool. A different argument each time is not \
1889                             the same thing as making progress."
1890                        ),
1891                        agent_hint: Some(
1892                            "If you are guessing at arguments to see which one lands, stop: \
1893                             report what you did find and say plainly what you could not \
1894                             determine. Otherwise reach for a different tool, or give your \
1895                             final answer with no tool call."
1896                                .into(),
1897                        ),
1898                        auto_fix: None,
1899                        location: None,
1900                    }]);
1901                }
1902            }
1903        }
1904        // ── Budget exhausted ─────────────────────────────────────────
1905        // Force a final synthesis pass with tools DISABLED. Otherwise the
1906        // model often spins on tool calls right up to the budget cap and
1907        // never emits a text conclusion, leaving the caller with nothing
1908        // but `last_text` from some earlier intermediate turn (or None).
1909        //
1910        // The synthesis call is "free" — it costs one extra model call
1911        // beyond max_iters but doesn't count toward `iters`. The result
1912        // lands in `last_text` so callers display it as the answer.
1913        //
1914        // Signal to any observer (LiveProgressHook, SessionRecorder, custom
1915        // hooks) that we've used 100% of the budget and are about to force
1916        // synthesis. Pre-existing `BudgetWarning` event was unused; this is
1917        // its natural home.
1918        self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
1919        let synthesised = self
1920            .force_final_synthesis(&mut ctx, world, &mut total_usage, BUDGET_SYNTHESIS_PROMPT)
1921            .await;
1922        if let Some(t) = synthesised {
1923            last_text = Some(t);
1924        }
1925
1926        self.hooks.fire(&Event::SessionEnd, world);
1927        self.run_learning_review(&ctx, world, tools_called).await;
1928        Ok(Outcome::BudgetExhausted {
1929            iters: iters_done,
1930            last_text,
1931            tools_called,
1932            usage: total_usage,
1933            deadline_reached,
1934        })
1935    }
1936
1937    /// Drive `Model::stream()` and assemble the result into a `ModelOutput`,
1938    /// firing `Event::ModelTokenDelta` for each text fragment along the way.
1939    ///
1940    /// Adapters that don't implement real streaming (e.g. `GeminiNative` /
1941    /// `AnthropicNative` today) fall back to the default trait impl, which
1942    /// runs `complete()` and emits the whole reply as a single delta. That
1943    /// works — the loop sees one big `ModelDelta::Text(...)` followed by
1944    /// `Stop`, fires one big `ModelTokenDelta`, and proceeds. So enabling
1945    /// `streaming` is safe regardless of which provider the user picked.
1946    async fn complete_via_stream(
1947        &self,
1948        ctx: &Context,
1949        world: &mut World,
1950    ) -> Result<ModelOutput, HarnessError> {
1951        use futures::StreamExt;
1952        let mut stream = self
1953            .model
1954            .stream(ctx)
1955            .await
1956            .map_err(harness_core::HarnessError::Model)?;
1957        let mut text = String::new();
1958        let mut reasoning = String::new();
1959        let mut usage = Usage::default();
1960        let mut stop_reason = StopReason::EndTurn;
1961        // Insertion-ordered map: index → (id, name, args). We can't use the
1962        // tool-call id as the primary key because the stream may emit args
1963        // chunks before the first chunk that carries the id; the OpenAI-compat
1964        // SSE parser already does its own buffering and surfaces `id` in
1965        // ToolCallStart, but be lenient with adapters that may interleave.
1966        let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
1967        let mut tool_order: Vec<String> = Vec::new();
1968        while let Some(item) = stream.next().await {
1969            let delta = item.map_err(harness_core::HarnessError::Model)?;
1970            match delta {
1971                ModelDelta::Text(t) => {
1972                    if !t.is_empty() {
1973                        self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
1974                        text.push_str(&t);
1975                    }
1976                }
1977                ModelDelta::ToolCallStart { id, name } => {
1978                    if !tool_starts.contains_key(&id) {
1979                        tool_order.push(id.clone());
1980                    }
1981                    tool_starts
1982                        .entry(id)
1983                        .or_insert_with(|| (name, String::new()));
1984                }
1985                ModelDelta::ToolCallArgs { id, partial_json } => {
1986                    let entry = tool_starts
1987                        .entry(id.clone())
1988                        .or_insert_with(|| (String::new(), String::new()));
1989                    if !tool_order.iter().any(|k| k == &id) {
1990                        tool_order.push(id);
1991                    }
1992                    entry.1.push_str(&partial_json);
1993                }
1994                ModelDelta::ToolCallEnd { .. } => {}
1995                ModelDelta::Usage(u) => usage = u,
1996                ModelDelta::Stop(r) => stop_reason = r,
1997                ModelDelta::Reasoning(s) => {
1998                    // Streamed reasoning arrives as token fragments, not lines —
1999                    // concatenate verbatim (same as `text`), don't insert newlines.
2000                    reasoning.push_str(&s);
2001                }
2002                // ModelDelta is `#[non_exhaustive]`; ignore future variants
2003                // we don't yet understand.
2004                _ => {}
2005            }
2006        }
2007        let tool_calls: Vec<ToolCall> = tool_order
2008            .into_iter()
2009            .filter_map(|id| {
2010                tool_starts.remove(&id).map(|(name, args)| {
2011                    let args_v = serde_json::from_str::<serde_json::Value>(&args)
2012                        .unwrap_or(serde_json::Value::String(args));
2013                    ToolCall {
2014                        id,
2015                        name,
2016                        args: args_v,
2017                    }
2018                })
2019            })
2020            .collect();
2021        // Reconcile stop_reason with what actually came out — adapters
2022        // sometimes emit `Stop(EndTurn)` even after tool_calls, which would
2023        // confuse downstream consumers that branch on stop_reason alone.
2024        let stop_reason = if !tool_calls.is_empty() {
2025            StopReason::ToolUse
2026        } else {
2027            stop_reason
2028        };
2029        Ok(ModelOutput {
2030            text: if text.is_empty() { None } else { Some(text) },
2031            tool_calls,
2032            usage,
2033            stop_reason,
2034            reasoning: if reasoning.is_empty() {
2035                None
2036            } else {
2037                Some(reasoning)
2038            },
2039            // `ModelDelta` has no image variant: the verified image-output
2040            // path (Gemini image models over chat) answers non-streamed, so
2041            // there is nothing to accumulate here. A streaming provider that
2042            // emits images would need a `ModelDelta::Image` first.
2043            images: Vec::new(),
2044        })
2045    }
2046
2047    /// Best-effort append to the recall store. Never fails the turn.
2048    /// One tool call, under the per-call deadline. A timeout becomes an error
2049    /// *result* — the model sees it and routes around it — never a hung run.
2050    /// Errors are folded the same way: the loop's contract is that a tool call
2051    /// always produces a result turn.
2052    async fn dispatch_bounded(&self, action: &Action, world: &mut World) -> ToolResult {
2053        let fut = self.tools.dispatch(action, world);
2054        let dispatched = match self.tool_timeout {
2055            Some(deadline) => match tokio::time::timeout(deadline, fut).await {
2056                Ok(r) => r,
2057                Err(_) => {
2058                    tracing::warn!(
2059                        target: "harness.telemetry",
2060                        event = "tool.deadline",
2061                        "gen_ai.tool.name" = %action.tool,
2062                        seconds = deadline.as_secs(),
2063                    );
2064                    return ToolResult {
2065                        ok: false,
2066                        content: serde_json::json!({
2067                            "error": format!(
2068                                "tool call exceeded its {}s deadline and was cancelled; \
2069                                 the operation may be too broad — narrow it or try a \
2070                                 different approach",
2071                                deadline.as_secs()
2072                            ),
2073                            "timeout": true,
2074                        }),
2075                        trace: None,
2076                    };
2077                }
2078            },
2079            None => fut.await,
2080        };
2081        dispatched.unwrap_or_else(|e| ToolResult {
2082            ok: false,
2083            content: serde_json::json!({"error": e.to_string()}),
2084            trace: None,
2085        })
2086    }
2087
2088    /// What a tool result contributes to the context: repeat suppression first,
2089    /// then the size ceiling. A repeat that is also oversized collapses to the
2090    /// pointer rather than to a truncated copy of what the model already holds.
2091    fn shape_result(
2092        &self,
2093        action: &Action,
2094        result: &ToolResult,
2095        answered: &mut std::collections::HashSet<String>,
2096        root: &std::path::Path,
2097    ) -> serde_json::Value {
2098        if !(self.tool_results.dedupe_repeats && result.ok) {
2099            return self.cap_result(action, &result.content, root);
2100        }
2101        match self.tools.risk(&action.tool) {
2102            Some(harness_core::ToolRisk::ReadOnly) => {
2103                let fp = format!("{}({})", action.tool, action.args);
2104                if answered.contains(&fp) {
2105                    tracing::info!(
2106                        target: "harness.telemetry",
2107                        event = "tool.result.repeat",
2108                        "gen_ai.tool.name" = %action.tool,
2109                    );
2110                    serde_json::json!({
2111                        "repeat_of_earlier_call": true,
2112                        "tool": action.tool,
2113                        "note": "You already made this exact call in this run and nothing has \
2114                                 changed the workspace since. The earlier result above still \
2115                                 stands — use it rather than asking again.",
2116                    })
2117                } else {
2118                    answered.insert(fp);
2119                    self.cap_result(action, &result.content, root)
2120                }
2121            }
2122            // A write invalidates every earlier read.
2123            _ => {
2124                answered.clear();
2125                self.cap_result(action, &result.content, root)
2126            }
2127        }
2128    }
2129
2130    /// Enforce [`ToolResultPolicy`] on one result before it reaches the context.
2131    ///
2132    /// Over the ceiling, the guard prefers to *spill*: full payload to a file
2133    /// inside the workspace, bounded preview plus the path inline — nothing is
2134    /// lost, and the model retrieves slices with the file tools it already has.
2135    /// Only when spilling is off (or the write fails) does it fall back to
2136    /// destructive truncation: a byte-level cut handed back as a marker object
2137    /// rather than mangled JSON, saying how much was dropped and what to do
2138    /// instead.
2139    fn cap_result(
2140        &self,
2141        action: &Action,
2142        content: &serde_json::Value,
2143        root: &std::path::Path,
2144    ) -> serde_json::Value {
2145        let Some(max) = self.tool_results.max_bytes else {
2146            return content.clone();
2147        };
2148        let serialized = content.to_string();
2149        if serialized.len() <= max {
2150            return content.clone();
2151        }
2152        if self.tool_results.spill
2153            && let Some(marker) = spill_oversized(action, content, &serialized, root)
2154        {
2155            return marker;
2156        }
2157        // Cut on a char boundary so the kept head is valid UTF-8.
2158        let mut end = max;
2159        while end > 0 && !serialized.is_char_boundary(end) {
2160            end -= 1;
2161        }
2162        tracing::warn!(
2163            target: "harness.telemetry",
2164            event = "tool.result.truncated",
2165            "gen_ai.tool.name" = %action.tool,
2166            bytes = serialized.len(),
2167            max_bytes = max,
2168        );
2169        serde_json::json!({
2170            "truncated": true,
2171            "tool": action.tool,
2172            "bytes_total": serialized.len(),
2173            "bytes_kept": end,
2174            "head": serialized[..end],
2175            "note": format!(
2176                "This result was {} bytes and was cut to {} to protect the context window. \
2177                 Do not ask for it again unchanged — narrow it: request a smaller range, \
2178                 a filter, or a specific field.",
2179                serialized.len(), end
2180            ),
2181        })
2182    }
2183
2184    async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
2185        if let Some(store) = &self.recall
2186            && let Err(e) = store.append(owner, session, &msg).await
2187        {
2188            tracing::warn!(error = %e, "recall append failed");
2189        }
2190    }
2191
2192    /// Best-effort post-session review. Never affects the finished run.
2193    async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
2194        let Some(cfg) = &self.learning else { return };
2195        if tools_called < cfg.nudge_interval {
2196            return;
2197        }
2198        let transcript = crate::render_transcript(&ctx.history, 12_000);
2199        let task = harness_core::Task {
2200            description: format!(
2201                "{}\n\n## Conversation transcript\n{}",
2202                cfg.review_prompt, transcript
2203            ),
2204            source: None,
2205            deadline: None,
2206        };
2207        let mut spec =
2208            crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
2209        for t in &cfg.tools {
2210            spec = spec.with_tool(t.clone());
2211        }
2212        let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
2213        // Box::pin breaks the recursive async-future cycle: AgentLoop<M> →
2214        // run_learning_review → Subagent<DynModel>::run →
2215        // AgentLoop<Arc<dyn Model>>::run_built_context. Without pinning the
2216        // compiler rejects the infinite-sized future.
2217        if let Err(e) = Box::pin(sub.run(world)).await {
2218            tracing::warn!(error = %e, "learning review failed");
2219        }
2220    }
2221
2222    /// One final model call with tools removed, asking it to write the
2223    /// best-effort conclusion from whatever it has already gathered.
2224    ///
2225    /// Errors from the model are swallowed — observability is best-effort
2226    /// here, and a transport blip during synthesis should not turn a
2227    /// near-complete run into a hard failure.
2228    ///
2229    /// `prompt` says *why* the loop stopped granting tool calls. Budget
2230    /// exhaustion is not the only way a run can end with the model still
2231    /// reaching for tools — a detected loop is another — and the model writes a
2232    /// better final answer when it is told which one happened.
2233    async fn force_final_synthesis(
2234        &self,
2235        ctx: &mut Context,
2236        world: &mut World,
2237        total_usage: &mut harness_core::Usage,
2238        prompt: &str,
2239    ) -> Option<String> {
2240        // Snapshot + clear tool schemas so the model has no choice but text.
2241        let saved_tools = std::mem::take(&mut ctx.tools);
2242        ctx.history.push(Turn {
2243            role: TurnRole::User,
2244            blocks: vec![Block::Text(prompt.into())],
2245        });
2246
2247        self.hooks.fire(&Event::PreModel { ctx }, world);
2248        let result = self.model.complete(ctx).await;
2249        ctx.tools = saved_tools;
2250
2251        match result {
2252            Ok(out) => {
2253                self.hooks.fire(&Event::PostModel { out: &out }, world);
2254                total_usage.input_tokens += out.usage.input_tokens;
2255                total_usage.output_tokens += out.usage.output_tokens;
2256                total_usage.cached_input_tokens += out.usage.cached_input_tokens;
2257                total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
2258                ctx.push_model_output(&out);
2259                out.text
2260            }
2261            Err(_) => None,
2262        }
2263    }
2264}
2265
2266/// A persistent multi-turn conversation over one [`AgentLoop`].
2267///
2268/// Holds the append-only history and, on each [`turn`](Session::turn), re-runs
2269/// the loop against a **stable prefix** (system + name-sorted tool schemas).
2270/// That byte-stable prefix is what lets a provider's prefix cache hit across
2271/// turns — the difference between paying full price to re-read the same context
2272/// every round and paying ~10% for the cached bytes (DeepSeek).
2273pub struct Session<'a, M: Model> {
2274    loop_: &'a AgentLoop<M>,
2275    history: Vec<Turn>,
2276    max_iters: u32,
2277}
2278
2279impl<'a, M: Model> Session<'a, M> {
2280    pub fn with_max_iters(mut self, n: u32) -> Self {
2281        self.max_iters = n;
2282        self
2283    }
2284    /// Preload prior turns (e.g. resumed from disk).
2285    pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
2286        self.history = seed;
2287        self
2288    }
2289    /// The accumulated conversation so far.
2290    pub fn history(&self) -> &[Turn] {
2291        &self.history
2292    }
2293    /// Start over (branch): drop the accumulated turns.
2294    pub fn reset(&mut self) {
2295        self.history.clear();
2296    }
2297
2298    /// Send one user message. Runs the ReAct loop against the accumulated
2299    /// history, then appends this user turn + the assistant reply so the next
2300    /// turn extends the same cached prefix.
2301    pub async fn turn(
2302        &mut self,
2303        message: impl Into<String>,
2304        world: &mut World,
2305    ) -> Result<Outcome, HarnessError> {
2306        let message = message.into();
2307        let task = Task {
2308            description: message.clone(),
2309            source: None,
2310            deadline: None,
2311        };
2312        let outcome = self
2313            .loop_
2314            .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
2315            .await?;
2316        let reply = match &outcome {
2317            Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
2318            Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
2319                last_text.clone().unwrap_or_default()
2320            }
2321        };
2322        self.history.push(Turn {
2323            role: TurnRole::User,
2324            blocks: vec![Block::Text(message)],
2325        });
2326        self.history.push(Turn {
2327            role: TurnRole::Assistant,
2328            blocks: vec![Block::Text(reply)],
2329        });
2330        Ok(outcome)
2331    }
2332}
2333
2334/// Audit #7: default safelist for `FixPatch::RunCommand`.
2335///
2336/// Sensors emitting `RunCommand` patches would otherwise be a silent
2337/// arbitrary-code-execution channel. We restrict the *program* by name to a
2338/// short list of well-known, side-effect-bounded formatters/fixers. Anything
2339/// else returns false and the patch is rejected (write your own `PreAutoFix`
2340/// hook returning `HookOutcome::Allow` to widen the policy).
2341///
2342/// `ReplaceFile` and `UnifiedDiff` are not restricted here — they only touch
2343/// files inside the workspace and are covered by the symlink-safe path
2344/// resolution in `harness-tools-fs`.
2345pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
2346    use harness_core::FixPatch;
2347    match patch {
2348        FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
2349        FixPatch::RunCommand { program, args, .. } => match program.as_str() {
2350            // Cargo subcommands proven side-effect-bounded.
2351            "cargo" => matches!(
2352                args.first().map(String::as_str),
2353                Some("fmt" | "clippy" | "fix"),
2354            ),
2355            "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
2356            _ => false,
2357        },
2358        // Future FixPatch variants: deny by default — review and add to the list above.
2359        _ => false,
2360    }
2361}
2362
2363/// Monotonic counter for `.harness-patch-*.diff` temp filenames — millisecond
2364/// resolution alone collides under parallel agent runs.
2365static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2366
2367/// Monotonic counter for fallback recall session ids (no `uuid` dep).
2368static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2369
2370/// Apply auto-fix patches; return short descriptions of those that succeeded.
2371///
2372/// Made `pub` (was `pub(crate)`) so integration tests can call it directly.
2373pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
2374    use harness_core::FixPatch;
2375    let mut applied = Vec::new();
2376    for p in patches {
2377        match p {
2378            FixPatch::ReplaceFile { path, content } => {
2379                let abs = world.repo.root.join(path);
2380                if let Some(parent) = abs.parent() {
2381                    let _ = tokio::fs::create_dir_all(parent).await;
2382                }
2383                if tokio::fs::write(&abs, content).await.is_ok() {
2384                    applied.push(format!("replaced {}", path.display()));
2385                }
2386            }
2387            FixPatch::UnifiedDiff { diff } => {
2388                if try_apply_diff(world, diff).await {
2389                    applied.push("unified diff applied".into());
2390                }
2391            }
2392            FixPatch::RunCommand { program, args, cwd } => {
2393                let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
2394                let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
2395                if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
2396                    && out.status == 0
2397                {
2398                    applied.push(format!("ran `{program} {}`", args.join(" ")));
2399                }
2400            }
2401            // FixPatch is `#[non_exhaustive]`; unknown variants are skipped.
2402            _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
2403        }
2404    }
2405    applied
2406}
2407
2408/// Write `diff` to a unique temp file and try `patch -p1` first, then `-p0`.
2409/// Returns whether either succeeded. The `-p1`-then-`-p0` order matches the
2410/// reality that most agent-emitted diffs are git-style (need `-p1`) but some
2411/// hand-rolled diffs use repo-relative paths (need `-p0`).
2412async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
2413    use std::sync::atomic::Ordering;
2414    use tokio::io::AsyncWriteExt;
2415
2416    let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
2417    let pid = std::process::id();
2418    let now = world.clock.now_ms();
2419    let tmp = world
2420        .repo
2421        .root
2422        .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
2423
2424    let mut f = match tokio::fs::File::create(&tmp).await {
2425        Ok(f) => f,
2426        Err(e) => {
2427            tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
2428            return false;
2429        }
2430    };
2431    if let Err(e) = f.write_all(diff.as_bytes()).await {
2432        tracing::warn!(error=%e, "could not write patch tempfile");
2433        let _ = tokio::fs::remove_file(&tmp).await;
2434        return false;
2435    }
2436    drop(f);
2437
2438    let tmp_str = tmp.to_string_lossy().to_string();
2439    let mut applied = false;
2440    for strip in ["-p1", "-p0"] {
2441        match world
2442            .runner
2443            .exec(
2444                "patch",
2445                &[strip, "--silent", "-i", tmp_str.as_str()],
2446                Some(world.repo.root.as_path()),
2447            )
2448            .await
2449        {
2450            Ok(out) if out.status == 0 => {
2451                tracing::info!(strip, "patch applied");
2452                applied = true;
2453                break;
2454            }
2455            Ok(out) => {
2456                tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
2457            }
2458            Err(e) => {
2459                tracing::warn!(error=%e, "patch command not available");
2460                break; // patch tool missing — no point trying other strip
2461            }
2462        }
2463    }
2464    let _ = tokio::fs::remove_file(&tmp).await;
2465    applied
2466}