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