Skip to main content

harness_loop/
lib.rs

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