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}