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}