Skip to main content

harness_loop/
lib.rs

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