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