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