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