Skip to main content

harness_loop/
lib.rs

1//! ReAct agent loop with self-correction.
2//!
3//! Minimal v0.0.1 implementation:
4//! - Applies guides once at the start.
5//! - Sends `Context` (with `tools`) to the model.
6//! - Dispatches each returned tool call via [`ToolRegistry`].
7//! - Runs `Sensor::SelfCorrect` sensors after each action; auto-fix patches are
8//!   applied directly to the world, blocking signals are fed back to the model.
9//! - Stops when the model returns no tool calls, when `policy.max_iters` is
10//!   hit, or when a stuck-detector ([`StuckPolicy`], [`MonotonyPolicy`]) says
11//!   the run is spinning.
12
13// ── crates that used to be their own ───────────────────────────────────────
14// Ten of them, and not one carried a dependency a caller would want to avoid.
15// The boundaries bought nothing and cost every release ten more publishes and
16// every host ten more version pins; a crate boundary here has to name a
17// dependency you might not want, and these did not.
18pub mod blueprint;
19pub mod compactor;
20pub mod daemon;
21pub mod experience;
22pub mod hooks;
23pub mod loop_engine;
24pub mod orchestrator;
25pub mod sandbox;
26pub mod scheduler;
27pub mod templates;
28
29pub mod acceptance;
30pub mod goal;
31pub mod learning;
32pub mod memory_layer;
33pub mod meters;
34#[cfg(feature = "otel")]
35pub mod otel;
36pub mod profile_guide;
37pub mod recall_layer;
38pub mod receipt;
39pub mod registry;
40pub mod seal;
41pub use acceptance::{Acceptance, FilesExist, NonEmptyAnswer, Verdict};
42pub use goal::{Goal, GoalStore, Phase, PhaseStatus};
43pub use receipt::{Receipt, ReceiptBuilder};
44pub use seal::{SealBreach, SealSet};
45/// The token `AgentLoop::with_cancellation` takes, re-exported so a caller can
46/// cancel a run without depending on `tokio-util` directly.
47pub use tokio_util::sync::CancellationToken;
48pub mod replay;
49pub mod subagent;
50pub mod telemetry;
51pub mod timeline;
52
53pub use learning::*;
54pub use memory_layer::*;
55pub mod boundary_guide;
56pub use boundary_guide::*;
57pub use profile_guide::*;
58pub use recall_layer::*;
59pub use registry::*;
60pub use replay::*;
61pub use subagent::*;
62pub use telemetry::*;
63
64use crate::compactor::{CALIBRATION_KEY, DefaultCompactor};
65use crate::hooks::HookBus;
66use harness_core::{
67    Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
68    Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
69    StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
70};
71use std::collections::HashMap;
72use std::sync::Arc;
73use std::time::Duration;
74use tracing::Instrument;
75use tracing::field::Empty;
76
77/// What a finished model call leaves behind: fields on its `chat` span, and
78/// its duration and tokens as metrics.
79fn finish_chat(
80    span: &tracing::Span,
81    info: &harness_core::ModelInfo,
82    started: std::time::Instant,
83    result: Option<Result<&ModelOutput, String>>,
84) {
85    let end = match &result {
86        Some(Ok(out)) => {
87            span.record("gen_ai.usage.input_tokens", out.usage.input_tokens)
88                .record("gen_ai.usage.output_tokens", out.usage.output_tokens)
89                .record(
90                    "gen_ai.usage.cached_input_tokens",
91                    out.usage.cached_input_tokens,
92                )
93                .record(
94                    "gen_ai.response.finish_reasons",
95                    format!("{:?}", out.stop_reason),
96                )
97                .record("tool_calls", out.tool_calls.len());
98            meters::ModelCallEnd::Done(&out.usage)
99        }
100        Some(Err(e)) => {
101            span.record("otel.status_code", "error")
102                .record("otel.status_message", e.as_str());
103            meters::ModelCallEnd::Failed
104        }
105        None => {
106            span.record("otel.status_message", "cancelled");
107            meters::ModelCallEnd::Cancelled
108        }
109    };
110    meters::model_call(info, started.elapsed(), end);
111}
112
113/// A tool call's result and when it ran, by the World clock — the figures
114/// behind [`Event::ToolTimed`].
115struct Dispatched {
116    result: ToolResult,
117    started_ms: i64,
118    finished_ms: i64,
119}
120
121/// The span one model call runs in. Named `chat {model}` for OTLP backends,
122/// which group by span name; fields per the GenAI conventions.
123fn chat_span(info: &harness_core::ModelInfo) -> tracing::Span {
124    tracing::info_span!(
125        target: "harness.telemetry",
126        "chat",
127        "otel.name" = %format!("chat {}", info.model),
128        "otel.kind" = "client",
129        "otel.status_code" = Empty,
130        "otel.status_message" = Empty,
131        "gen_ai.operation.name" = "chat",
132        "gen_ai.provider.name" = %info.provider,
133        "gen_ai.request.model" = %info.model,
134        "gen_ai.usage.input_tokens" = Empty,
135        "gen_ai.usage.output_tokens" = Empty,
136        "gen_ai.usage.cached_input_tokens" = Empty,
137        "gen_ai.response.finish_reasons" = Empty,
138        tool_calls = Empty,
139    )
140}
141
142/// Governs the loop's stuck-detector. When the model repeats the *same* tool
143/// call (name + args) round after round without making progress, the loop first
144/// nudges it to change approach, then terminates cleanly with [`Outcome::Stuck`]
145/// rather than burning the rest of the budget spinning on a loop.
146///
147/// Enabled by default with conservative thresholds — the calls must be
148/// *byte-identical*, so genuine "read the same file twice" work never trips it.
149#[derive(Debug, Clone)]
150pub struct StuckPolicy {
151    pub enabled: bool,
152    /// Consecutive identical tool-call rounds before injecting a "you are
153    /// repeating yourself, change your approach" feedback signal.
154    pub nudge_after: u32,
155    /// Consecutive identical rounds before terminating with [`Outcome::Stuck`].
156    pub abort_after: u32,
157}
158
159impl Default for StuckPolicy {
160    fn default() -> Self {
161        Self {
162            enabled: true,
163            nudge_after: 3,
164            abort_after: 6,
165        }
166    }
167}
168
169/// A second stuck signal, orthogonal to [`StuckPolicy`]: the model calls *one*
170/// tool round after round — a different argument every time, so nothing ever
171/// repeats — and never moves on to anything else.
172///
173/// [`StuckPolicy`] cannot see this and must not be changed so that it can. It
174/// keys on *byte-identical consecutive* rounds precisely so that genuine "read
175/// the same file twice" work never trips it, and `graph_find("Xen")` followed
176/// by `graph_find("KVM")` is correctly not a repeat — the request changed. Two
177/// identical calls in a row and one tool worked for twenty rounds are different
178/// shapes, and want different thresholds and different reasons.
179///
180/// Measured on a real run: asked which platforms a product integrates with, an
181/// agent that could not retrieve the anchor entity began guessing candidates
182/// and looking them up one at a time — `graph_find("Nomad")`,
183/// `graph_find("Docker")`, `graph_find("VMware")`, `graph_find("Xen")`,
184/// `graph_find("KVM")` — twelve calls to one tool across twenty rounds, each
185/// with a different argument, none of them useful. Every individual call looked
186/// like progress. The *shape* did not, and the shape is something the loop can
187/// see without knowing anything about what `graph_find` means.
188///
189/// **Off by default, and it has to be.** The predicate is "one tool, many
190/// rounds, nothing else", and there is a large class of healthy agents whose
191/// every round is one tool: give an agent a shell and nothing else and
192/// `run_shell` is the only name it will ever emit, thirty rounds running,
193/// legitimately. No threshold rescues that — the false positive is unbounded in
194/// N, not merely rare — so a default-on version of this guard would terminate
195/// working agents, which is worse than the problem it fixes. A host that knows
196/// its agent has several tools and is meant to use them can switch it on;
197/// nobody else pays anything for its existence.
198#[derive(Debug, Clone)]
199pub struct MonotonyPolicy {
200    /// Off by default. See the type docs for why no default-on threshold is
201    /// safe for this predicate.
202    pub enabled: bool,
203    /// Consecutive single-tool rounds before injecting a "you are working one
204    /// tool and nothing else" feedback signal.
205    ///
206    /// 8, argued rather than rounded. The honest single-tool runs are surveys —
207    /// read five files, grep four paths — and a survey ends by using a
208    /// *different* tool, which resets the count; reaching eight rounds of one
209    /// name with nothing else touched is already past what surveying does. The
210    /// cost of being wrong here is one sentence of feedback, not a terminated
211    /// run, which is why the nudge can sit this close to ordinary work. This is
212    /// also the half of the policy that does the work: in the measured case a
213    /// nudge at round eight arrives with two thirds of the budget unspent.
214    pub nudge_after: u32,
215    /// Consecutive single-tool rounds before terminating with [`Outcome::Stuck`].
216    ///
217    /// 16 — twice the nudge, mirroring [`StuckPolicy`]'s own 3→6 ratio, and
218    /// 2.6× its abort so the two can never race: a byte-identical spiral is
219    /// always caught by the older policy first, under its own reason. Deliberately
220    /// generous, because this end of the policy is fatal and its only job is to
221    /// be a backstop for budgets long enough that the nudge was ignored.
222    pub abort_after: u32,
223}
224
225impl Default for MonotonyPolicy {
226    fn default() -> Self {
227        Self {
228            enabled: false,
229            nudge_after: 8,
230            abort_after: 16,
231        }
232    }
233}
234
235/// A ceiling on how much of one tool result reaches the context.
236///
237/// A single call can return more than the whole conversation: a lock file, a
238/// `SELECT *`, an MCP tool the framework does not control. Measured on a real
239/// run, "search these files for a word" cost 53,487 input tokens because one
240/// `read_file` returned a lock file — the model then paid for it on every
241/// subsequent turn, and compaction started throwing away real history to make
242/// room. A per-result ceiling is the only place to stop that: the tools cannot
243/// all be trusted (third-party MCP), and the compactor only runs after the
244/// damage is in the context.
245#[derive(Debug, Clone)]
246pub struct ToolResultPolicy {
247    /// Max serialized bytes of a single tool result. `None` disables the guard.
248    /// Default ~24 KiB — roughly 6k tokens of English, generous for a file page
249    /// or a query result, far below what blows a window.
250    pub max_bytes: Option<usize>,
251    /// Replace the payload of a read-only call that exactly repeats an earlier
252    /// one in the same run, when nothing has modified the world in between.
253    ///
254    /// [`StuckPolicy`] only sees *consecutive* identical rounds. Reading a file
255    /// at iteration 1 and again at iteration 5 is not that, and looks like
256    /// progress — but the same bytes land in the context twice and the model
257    /// learns nothing the second time. Measured on a real run, model wait was
258    /// 36.7s against 3ms of tool execution: a repeat costs context, not time,
259    /// so what is suppressed is the payload, not the call.
260    ///
261    /// Only `ToolRisk::ReadOnly` qualifies (`Network` is a separate risk, and an
262    /// external endpoint may answer differently), and any non-read-only call
263    /// clears the record — after a write, re-reading is the correct move.
264    ///
265    /// **Off by default, on the evidence.** Measured on the completion
266    /// benchmark: ceilings alone solved 6/6 tasks for 160k effective tokens;
267    /// adding repeat-suppression cut that to 32k — and lost a task. An agent
268    /// working through a file larger than one page re-reads it because it cannot
269    /// hold it, gets told it already has the answer, and does not: the content
270    /// was paged away. Five times cheaper is not worth a task a framework could
271    /// otherwise do, so this is opt-in for callers who would rather have the
272    /// tokens. (Suppressing from the *third* identical call rather than the
273    /// second would likely keep most of the saving without the failure — it
274    /// needs measuring before it becomes the default.)
275    pub dedupe_repeats: bool,
276    /// When a result exceeds `max_bytes`, save the *full* payload to a file
277    /// under `.harness/spill/` in the workspace and inline a bounded preview
278    /// plus the path, instead of cutting the tail off and throwing it away.
279    ///
280    /// Truncation destroys information: the model is told "narrow your
281    /// request", but the bytes it needed may be exactly the ones dropped, and
282    /// the only recovery is re-running the call to be truncated again.
283    /// Measured on the completion benchmark, that loop is visible as a single
284    /// task paying 184k input tokens. Spilling keeps the ceiling — the context
285    /// gets a preview, never the flood — while the whole result stays
286    /// retrievable through the file tools the agent already has (`read_file`
287    /// with offset/limit, `grep` on the spill path), because the spill lives
288    /// inside the workspace jail. Borrowed from DeepSeek Harness's `spill`
289    /// family (preview + retrieval locator), which is the same judgement.
290    ///
291    /// Costs nothing until it fires: the write happens only on the oversized
292    /// path, which the default ceiling makes rare. When the write fails (e.g.
293    /// read-only workspace) the guard falls back to plain truncation.
294    pub spill: bool,
295}
296
297impl Default for ToolResultPolicy {
298    fn default() -> Self {
299        Self {
300            max_bytes: Some(24 * 1024),
301            dedupe_repeats: false,
302            spill: true,
303        }
304    }
305}
306
307/// Governs *when* and *how far* the loop compacts context. Hysteresis: only
308/// start compacting once usage crosses `high_water`, and stop as soon as it's
309/// back under `target` — instead of running every stage above a threshold on
310/// every turn. This avoids over-compacting (needlessly reaching the lossy,
311/// main-model `AutoCompact` stage) and, because compaction rewrites history and
312/// invalidates the provider prefix cache, avoids nibbling the context every
313/// single turn.
314#[derive(Debug, Clone)]
315pub struct CompactPolicy {
316    /// Start compacting when `used/window` exceeds this. Default 0.75.
317    pub high_water: f32,
318    /// Stop as soon as `used/window` is back at/under this. Default 0.55.
319    pub target: f32,
320}
321
322impl Default for CompactPolicy {
323    fn default() -> Self {
324        Self {
325            high_water: 0.75,
326            target: 0.55,
327        }
328    }
329}
330
331/// Inline preview size for a spilled result. Deliberately smaller than the
332/// ceiling: the point of spilling is that the context gets a *glimpse* and a
333/// path, not four-fifths of the flood.
334const SPILL_PREVIEW_BYTES: usize = 4 * 1024;
335
336/// Told to the model when the step budget ran out and the loop stripped its
337/// tools. See `force_final_synthesis`.
338const BUDGET_SYNTHESIS_PROMPT: &str = "[system: budget exhausted] \
339    You have run out of budget for tool calls. Write your final answer \
340    NOW using only the tool results already in this conversation. Do not \
341    request more tools. Mark facts you could not verify as UNKNOWN. \
342    Include source URLs for every claim that is not UNKNOWN.";
343
344/// Told to the model when [`MonotonyPolicy`] stopped the run. Deliberately
345/// names the shape it was caught in: the failure mode this ends is a model
346/// guessing arguments to one tool, and such a model has usually *found*
347/// something it never got round to reporting. "Say what you could not
348/// determine" is the instruction it needs; "mark it UNKNOWN" is the same
349/// instruction the budget prompt gives for the same reason.
350const MONOTONY_SYNTHESIS_PROMPT: &str = "[system: stopped — no progress] \
351    You have called the same tool many rounds in a row without using any other \
352    tool, so no further tool calls will be granted. Write your final answer NOW \
353    using only the tool results already in this conversation. Do not request \
354    more tools. State plainly which parts you could not determine instead of \
355    guessing at them.";
356
357/// First `n` bytes of `s`, cut on a char boundary.
358fn head_of(s: &str, n: usize) -> &str {
359    let mut end = n.min(s.len());
360    while end > 0 && !s.is_char_boundary(end) {
361        end -= 1;
362    }
363    &s[..end]
364}
365
366/// The string field that dominates an oversized result, if one does.
367///
368/// Most oversized results are one big string in a small envelope — a file
369/// body, a command's stdout — and the right spill for those is the *raw text*:
370/// multi-line, so `read_file`'s line paging and `grep`'s line matching work on
371/// it. Spilling the serialized JSON instead would fold the whole payload onto
372/// one escaped line that line-oriented tools can neither page nor match
373/// (`read_file` pages by line; a 68 KB single-line file is unreachable past
374/// the first 16 KB). Returns the dotted path and the string when one field is
375/// ≥ 80% of the serialized size.
376fn dominant_string(v: &serde_json::Value, total: usize) -> Option<(String, &str)> {
377    fn walk<'a>(v: &'a serde_json::Value, path: &str, best: &mut Option<(String, &'a str)>) {
378        match v {
379            serde_json::Value::String(s) => {
380                if best.as_ref().is_none_or(|(_, b)| s.len() > b.len()) {
381                    *best = Some((path.to_string(), s.as_str()));
382                }
383            }
384            serde_json::Value::Object(m) => {
385                for (k, x) in m {
386                    let p = if path.is_empty() {
387                        k.clone()
388                    } else {
389                        format!("{path}.{k}")
390                    };
391                    walk(x, &p, best);
392                }
393            }
394            serde_json::Value::Array(a) => {
395                for (i, x) in a.iter().enumerate() {
396                    walk(x, &format!("{path}[{i}]"), best);
397                }
398            }
399            _ => {}
400        }
401    }
402    let mut best = None;
403    walk(v, "", &mut best);
404    best.filter(|(_, s)| s.len() * 5 >= total * 4)
405}
406
407/// Replace the value at a `dominant_string` dotted path with `with`.
408fn replace_at(v: &mut serde_json::Value, path: &str, with: serde_json::Value) {
409    let mut cur = v;
410    let mut rest = path;
411    loop {
412        // Next segment: `key`, `key[i]`, or a bare `[i]`.
413        let (seg, tail) = match rest.find('.') {
414            Some(dot) => (&rest[..dot], &rest[dot + 1..]),
415            None => (rest, ""),
416        };
417        let (key, idx) = match seg.find('[') {
418            Some(b) => (&seg[..b], seg[b + 1..seg.len() - 1].parse::<usize>().ok()),
419            None => (seg, None),
420        };
421        if !key.is_empty() {
422            match cur.get_mut(key) {
423                Some(next) => cur = next,
424                None => return,
425            }
426        }
427        if let Some(i) = idx {
428            match cur.get_mut(i) {
429                Some(next) => cur = next,
430                None => return,
431            }
432        }
433        if tail.is_empty() {
434            *cur = with;
435            return;
436        }
437        rest = tail;
438    }
439}
440
441/// Persist an oversized result under `.harness/spill/` in the workspace and
442/// build the inline marker. `None` on any IO failure — the caller falls back
443/// to truncation, because a guard that can error a run to protect a context
444/// window has its priorities backwards.
445fn spill_oversized(
446    action: &Action,
447    content: &serde_json::Value,
448    serialized: &str,
449    root: &std::path::Path,
450) -> Option<serde_json::Value> {
451    let dir = root.join(".harness").join("spill");
452    std::fs::create_dir_all(&dir).ok()?;
453    let id: String = action
454        .call_id
455        .chars()
456        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
457        .take(48)
458        .collect();
459    let id = if id.is_empty() { "call".into() } else { id };
460
461    let (rel, preview, meta) = match dominant_string(content, serialized.len()) {
462        // One big string in a small envelope: spill the raw text, keep the
463        // envelope inline with the big field swapped for a pointer.
464        Some((field, text)) => {
465            let rel = format!(".harness/spill/{id}-{}.txt", action.tool);
466            std::fs::write(root.join(&rel), text).ok()?;
467            let mut meta = content.clone();
468            replace_at(
469                &mut meta,
470                &field,
471                serde_json::Value::String(format!("[{} bytes spilled to {rel}]", text.len())),
472            );
473            (
474                rel,
475                head_of(text, SPILL_PREVIEW_BYTES).to_string(),
476                Some(meta),
477            )
478        }
479        // Structured payload (e.g. a long match list): spill pretty-printed,
480        // one element per line, so line-oriented retrieval still works.
481        None => {
482            let rel = format!(".harness/spill/{id}-{}.json", action.tool);
483            let pretty =
484                serde_json::to_string_pretty(content).unwrap_or_else(|_| serialized.to_string());
485            std::fs::write(root.join(&rel), &pretty).ok()?;
486            (rel, head_of(&pretty, SPILL_PREVIEW_BYTES).to_string(), None)
487        }
488    };
489    tracing::warn!(
490        target: "harness.telemetry",
491        event = "tool.result.spilled",
492        "gen_ai.tool.name" = %action.tool,
493        bytes = serialized.len(),
494        path = %rel,
495    );
496    let mut marker = serde_json::json!({
497        "spilled": true,
498        "tool": action.tool,
499        "bytes_total": serialized.len(),
500        "path": rel,
501        "preview": preview,
502        "note": format!(
503            "This result was {} bytes — too large to inline, so the FULL content was \
504             saved to '{rel}' (workspace-relative). Nothing was lost. Retrieve exactly \
505             the part you need: grep(path=\"{rel}\", pattern=...) or \
506             read_file(path=\"{rel}\", offset=..., limit=...). Do not repeat the \
507             original call — it will spill again.",
508            serialized.len()
509        ),
510    });
511    // The envelope metadata (paths, flags) rides along when it is small; a
512    // pathological envelope that is itself oversized is dropped, not inlined.
513    if let Some(meta) = meta
514        && meta.to_string().len() <= SPILL_PREVIEW_BYTES
515    {
516        marker["meta"] = meta;
517    }
518    Some(marker)
519}
520
521/// A stable fingerprint of a round's tool calls (names + args, ignoring the
522/// volatile call id). Two rounds with the same fingerprint asked for the exact
523/// same actions — the signal the stuck-detector keys on.
524fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
525    calls
526        .iter()
527        .map(|c| format!("{}({})", c.name, c.args))
528        .collect::<Vec<_>>()
529        .join("|")
530}
531
532/// The one tool a round asked for, when the whole round asked for exactly one
533/// *name*. `None` when the round mixed names — reaching for a second tool is,
534/// on its own, evidence the model is still moving, and resets
535/// [`MonotonyPolicy`]'s count.
536///
537/// Several parallel calls to the same tool count as one round of that tool:
538/// reading four files in one round is efficient work, not monotony, and the
539/// policy's threshold is in *rounds* precisely so that batching is never
540/// punished.
541fn sole_tool(calls: &[ToolCall]) -> Option<&str> {
542    let first = calls.first()?.name.as_str();
543    calls.iter().all(|c| c.name == first).then_some(first)
544}
545
546/// Cheap identity for one tool result, for the stuck detector's "did anything
547/// change" test. Not a checksum anyone depends on — only equality between two
548/// consecutive rounds of the same process matters.
549fn result_digest(tool: &str, content: &serde_json::Value) -> u64 {
550    use std::hash::{Hash, Hasher};
551    let mut h = std::collections::hash_map::DefaultHasher::new();
552    tool.hash(&mut h);
553    content.to_string().hash(&mut h);
554    h.finish()
555}
556
557/// Where a run finished. Each variant is `#[non_exhaustive]` so new *fields*
558/// don't break downstream matches — always include `..` when destructuring.
559#[derive(Debug, Clone)]
560pub enum Outcome {
561    /// Model returned text with no tool calls (natural end).
562    #[non_exhaustive]
563    Done {
564        text: Option<String>,
565        iters: u32,
566        tools_called: u32,
567        usage: harness_core::Usage,
568        /// What the acceptance checks said. `None` means nothing was asked —
569        /// so "the model stopped" is all this outcome claims.
570        verified: Option<Verdict>,
571        /// The sealed contract as it stood before the model's first turn.
572        ///
573        /// Carried out of the run because a receipt has to say *what* was
574        /// measured, not only that the measurement held. Empty when nothing
575        /// was sealed.
576        contract: crate::seal::SealSet,
577        /// Set when a sealed acceptance contract changed during the run.
578        ///
579        /// Distinct from a plain failed `verified` because the two mean
580        /// different things to a caller: a failed check is work not finished,
581        /// this is the measuring instrument having been moved. A host may want
582        /// to fail a build on the first and page someone on the second.
583        seal_breach: Option<String>,
584    },
585    /// Policy budget exhausted before the model stopped requesting tools.
586    /// Carries everything we know so the caller can recover partial work
587    /// (saved notes, files written by tools, the last assistant text, etc.)
588    /// instead of seeing a single bare "budget out" string.
589    #[non_exhaustive]
590    BudgetExhausted {
591        iters: u32,
592        last_text: Option<String>,
593        tools_called: u32,
594        usage: harness_core::Usage,
595        /// True when `Task::deadline` stopped the run rather than `max_iters`.
596        /// Both end the same way — a forced final answer — but "ran out of
597        /// time" and "ran out of steps" call for different responses from the
598        /// caller, and an unattended long run only ever hits the first.
599        deadline_reached: bool,
600    },
601    /// The agent got stuck and the loop terminated early to save the rest of
602    /// the budget. Carries partial work (last text, files already written by
603    /// tools) like `BudgetExhausted`.
604    ///
605    /// Two independent detectors end here, and `reason` says which: it repeated
606    /// the *same* tool call for `StuckPolicy::abort_after` rounds without
607    /// progress, or it worked a *single* tool for `MonotonyPolicy::abort_after`
608    /// rounds without touching anything else. Only the second forces a final
609    /// synthesis before returning — see the abort site for why.
610    #[non_exhaustive]
611    Stuck {
612        /// Human-readable reason, e.g. "repeated `read_file(...)` 6× without progress"
613        /// or "called `graph_find` for 16 consecutive rounds without using any
614        /// other tool".
615        reason: String,
616        /// How many consecutive rounds the detector counted before aborting.
617        repeated: u32,
618        iters: u32,
619        last_text: Option<String>,
620        tools_called: u32,
621        usage: harness_core::Usage,
622    },
623    /// The caller stopped the run through [`AgentLoop::with_cancellation`]
624    /// before the model reached a natural end. Carries partial work like
625    /// `Stuck` does, because the user who pressed Ctrl-C still wants what was
626    /// done. The loop makes **no** further model call after this — not even
627    /// the forced final synthesis the other early exits perform.
628    ///
629    /// A tool that was mid-flight when the token fired may still finish its
630    /// side effects — a file write on a blocking thread completes, a child
631    /// process keeps running unless its runner kills it on drop — but its
632    /// result is discarded before it reaches history. The dispatch is still
633    /// counted in `tools_called` — the telemetry `run.end` line reconciles
634    /// against that number.
635    #[non_exhaustive]
636    Cancelled {
637        /// Iterations completed before the cancel. `0` means the token was
638        /// already cancelled on entry.
639        iters: u32,
640        last_text: Option<String>,
641        tools_called: u32,
642        usage: harness_core::Usage,
643    },
644}
645
646/// The agent loop.
647pub struct AgentLoop<M: Model> {
648    pub model: M,
649    pub tools: ToolRegistry,
650    pub guides: Vec<Arc<dyn Guide>>,
651    pub sensors: Vec<Arc<dyn Sensor>>,
652    pub hooks: HookBus,
653    pub compactor: Arc<dyn Compactor>,
654    /// A deadline on each individual tool call. One hung call — a network tool
655    /// on a dead endpoint, a shell command waiting on stdin — otherwise takes
656    /// the whole run down with it, and the host's only recourse is a run-level
657    /// timeout that throws away every turn of finished work (measured on the
658    /// completion benchmark: a run that had already done the job was billed as
659    /// a 0-token timeout). A per-call deadline converts the hang into an error
660    /// *result* the model sees and can route around. `None` disables. The
661    /// default is generous — 120s covers a slow build — because a false
662    /// deadline on a legitimately long tool is worse than a late one.
663    pub tool_timeout: Option<Duration>,
664    /// Default response format applied to every run unless overridden by
665    /// `run_typed`. See [`ResponseFormat`].
666    pub response_format: ResponseFormat,
667    /// When `true`, the loop drives each model turn via `Model::stream()`
668    /// instead of `complete()`, firing `Event::ModelTokenDelta` for each
669    /// text fragment. Tool-call deltas are still assembled inside the loop;
670    /// only the terminal `ModelOutput` shape is observable downstream.
671    pub streaming: bool,
672    /// Stops the run from outside. Checked at the top of every iteration and
673    /// raced against the model step and every tool dispatch, so a cancel
674    /// lands within one await point rather than at the next iteration
675    /// boundary. Defaults to a token nobody holds, which never fires.
676    pub cancel: CancellationToken,
677    /// Optional cross-session recall store. When set, the loop captures every
678    /// turn and the `session_search` tool is registered. See `with_recall`.
679    pub recall: Option<Arc<dyn harness_core::RecallStore>>,
680    /// When true (and `recall` is set), a `RecallGuide` auto-injects top-k
681    /// past context at session start.
682    pub recall_auto_inject: bool,
683    pub learning: Option<LearningConfig>,
684    /// Loop-detection policy. Enabled by default — see [`StuckPolicy`].
685    pub stuck: StuckPolicy,
686    /// Second, independent loop-detection signal: one tool worked round after
687    /// round with nothing else touched. Off by default — see [`MonotonyPolicy`].
688    pub monotony: MonotonyPolicy,
689    /// Ceiling on context tokens, overriding the model's own window. `None`
690    /// (default) sizes the budget to `ModelInfo::context_window`.
691    pub max_input_tokens: Option<u32>,
692    /// Context-compaction hysteresis. See [`CompactPolicy`].
693    pub compaction: CompactPolicy,
694    /// Ceiling on a single tool result. See [`ToolResultPolicy`].
695    pub tool_results: ToolResultPolicy,
696    /// Conditions the run must satisfy before the loop reports success. The
697    /// model stopping is evidence it *believes* it is finished; these say
698    /// whether it is. See [`acceptance`].
699    pub acceptance: Vec<Arc<dyn Acceptance>>,
700    /// How many times a failed acceptance is handed back to the model before
701    /// the loop gives up and reports what there is. One is usually enough: a
702    /// model that ignores the first correction rarely takes the second.
703    pub acceptance_retries: u32,
704    /// System instruction injected into every run's `Context.system` (unless the
705    /// built context already carries its own). Set via [`with_system`](Self::with_system).
706    pub system: Vec<Block>,
707    /// Named auxiliary models for side tasks — compaction, memory synthesis,
708    /// judging, subagents — so the main conversation stays on one model (and
709    /// keeps its provider prompt-cache prefix intact) while cheap or specialist
710    /// work goes elsewhere. Populated via [`with_model_role`](Self::with_model_role),
711    /// read via [`model_for`](Self::model_for). An unregistered role means
712    /// "use the main model" — components fall back rather than fail.
713    pub model_roles: std::collections::HashMap<String, Arc<dyn Model>>,
714    /// True once the host installed its own compactor via
715    /// [`with_compactor`](Self::with_compactor). Guards the `"compactor"`
716    /// model-role convenience from overwriting an explicit choice.
717    pub compactor_custom: bool,
718}
719
720impl AgentLoop<harness_core::DynModel> {
721    /// Build a loop from a boxed model — what every model factory hands back
722    /// (`ApiKind::build`, a router, anything stored behind a trait object).
723    ///
724    /// `Arc<dyn Model>` deliberately does not implement `Model` (see
725    /// [`DynModel`](harness_core::DynModel) for why), so `AgentLoop::new` cannot
726    /// take one. Without this constructor every caller writes the wrapper
727    /// themselves, and the first thing a new user meets is a trait-bound error
728    /// naming a type they have never heard of.
729    ///
730    /// ```ignore
731    /// let model = ApiKind::OpenAI.build(base_url, model_id, key);
732    /// let agent = AgentLoop::boxed(model).with_tool(Arc::new(ReadFile));
733    /// ```
734    pub fn boxed(model: Arc<dyn Model>) -> Self {
735        Self::new(harness_core::DynModel(model))
736    }
737}
738
739impl<M: Model> AgentLoop<M> {
740    pub fn new(model: M) -> Self {
741        Self {
742            model,
743            tools: ToolRegistry::new(),
744            guides: Vec::new(),
745            sensors: Vec::new(),
746            hooks: HookBus::new(),
747            compactor: Arc::new(DefaultCompactor::new()),
748            tool_timeout: Some(Duration::from_secs(120)),
749            response_format: ResponseFormat::Free,
750            streaming: false,
751            cancel: CancellationToken::new(),
752            recall: None,
753            recall_auto_inject: false,
754            learning: None,
755            stuck: StuckPolicy::default(),
756            monotony: MonotonyPolicy::default(),
757            max_input_tokens: None,
758            compaction: CompactPolicy::default(),
759            // On by default, because the failure it catches is invisible: a
760            // turn that produced nothing is reported as a turn that finished.
761            tool_results: ToolResultPolicy::default(),
762            acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
763            acceptance_retries: 1,
764            system: Vec::new(),
765            model_roles: std::collections::HashMap::new(),
766            compactor_custom: false,
767        }
768    }
769
770    /// Register an auxiliary model under a role name — the loop's seam for
771    /// "side tasks don't have to run on the main model".
772    ///
773    /// The main conversation stays pinned to one model, which keeps its
774    /// provider prompt-cache prefix byte-stable; compaction summaries, memory
775    /// synthesis, judging, and subagents can go to a cheaper or specialist
776    /// model instead. Components read roles via [`model_for`](Self::model_for)
777    /// and fall back to the main model when a role is unregistered.
778    ///
779    /// One role is wired automatically: registering `"compactor"` upgrades the
780    /// default structural compactor to a [`ModelBackedCompactor`] on that
781    /// model — unless the host already installed its own via
782    /// [`with_compactor`](Self::with_compactor), which always wins regardless
783    /// of call order.
784    ///
785    /// ```ignore
786    /// let agent = AgentLoop::boxed(main)
787    ///     .with_model_role("compactor", cheap.clone())
788    ///     .with_model_role("judge", strong);
789    /// ```
790    pub fn with_model_role(mut self, role: impl Into<String>, model: Arc<dyn Model>) -> Self {
791        let role = role.into();
792        if role == "compactor" && !self.compactor_custom {
793            self.compactor = Arc::new(crate::compactor::ModelBackedCompactor::new(model.clone()));
794        }
795        self.model_roles.insert(role, model);
796        self
797    }
798
799    /// Look up an auxiliary model by role. `None` means "no model registered
800    /// for this role — use the main model"; callers fall back rather than fail,
801    /// so wiring stays optional everywhere.
802    pub fn model_for(&self, role: &str) -> Option<Arc<dyn Model>> {
803        self.model_roles.get(role).cloned()
804    }
805
806    /// Set a system instruction applied to every run (into `Context.system`) —
807    /// e.g. "answer only via the governed tools; never claim you can't access
808    /// data; never invent numbers". This is the first-class seam for a system
809    /// prompt; small local models in particular need it to reliably call tools
810    /// instead of refusing or hallucinating.
811    pub fn with_system(mut self, text: impl Into<String>) -> Self {
812        self.system = vec![Block::Text(text.into())];
813        self
814    }
815
816    /// Cap context tokens below the model's own window.
817    ///
818    /// Compaction fires at a fraction of this, so it is also the only way to
819    /// exercise the compaction path without first paying for a hundred
820    /// thousand tokens of real conversation — which is how "does a long run
821    /// survive being compacted" stayed untested.
822    pub fn with_max_input_tokens(mut self, n: u32) -> Self {
823        self.max_input_tokens = Some(n);
824        self
825    }
826
827    /// Override the loop-detection policy (thresholds, or disable entirely).
828    pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
829        self.stuck = policy;
830        self
831    }
832
833    /// Opt in to (or retune) the single-tool monotony signal. Independent of
834    /// [`with_stuck_policy`](Self::with_stuck_policy): both detectors run, with
835    /// their own thresholds and their own `Outcome::Stuck` reason.
836    ///
837    /// ```ignore
838    /// let agent = AgentLoop::boxed(model)
839    ///     .with_monotony_policy(MonotonyPolicy { enabled: true, ..Default::default() });
840    /// ```
841    pub fn with_monotony_policy(mut self, policy: MonotonyPolicy) -> Self {
842        self.monotony = policy;
843        self
844    }
845
846    /// Override the compaction hysteresis policy. See [`CompactPolicy`].
847    /// Set the ceiling on a single tool result. See [`ToolResultPolicy`].
848    pub fn with_tool_result_policy(mut self, policy: ToolResultPolicy) -> Self {
849        self.tool_results = policy;
850        self
851    }
852
853    pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
854        self.compaction = policy;
855        self
856    }
857
858    /// Opt in to streaming the model's terminal turn token-by-token via
859    /// `Model::stream()`. Hooks subscribed to `Event::ModelTokenDelta` see
860    /// each fragment as it arrives; the rest of the loop is unchanged.
861    pub fn with_streaming(mut self, enable: bool) -> Self {
862        self.streaming = enable;
863        self
864    }
865
866    /// Hand the run a token the caller can cancel from anywhere.
867    ///
868    /// The token belongs to the **loop, not to one run**, and cancellation is
869    /// **one-way**: once `cancel()` has been called, every later `run` (and
870    /// every later `Session::turn`) on this loop returns `Outcome::Cancelled`
871    /// immediately, with `iters: 0`. That is right for a loop built for one
872    /// job and wrong for one that serves many turns. For per-turn cancellation
873    /// give each turn its own token — either rebuild the loop, or keep a parent
874    /// and hand out `parent.child_token()`, dropping the child when the turn
875    /// ends so the parent stays live:
876    ///
877    /// ```ignore
878    /// let token = CancellationToken::new();
879    /// let agent = AgentLoop::new(model).with_cancellation(token.clone());
880    /// // … later, from anywhere — this loop is now finished with, permanently:
881    /// token.cancel();
882    /// ```
883    ///
884    /// The field is public, so the simplest per-turn form is to assign
885    /// `loop_.cancel = fresh_token` before each run, which is what
886    /// `harness code` does.
887    ///
888    /// Cloning a `CancellationToken` shares it, so keep one and pass a clone.
889    pub fn with_cancellation(mut self, token: CancellationToken) -> Self {
890        self.cancel = token;
891        self
892    }
893
894    /// Add a condition the run must satisfy before it can report success.
895    pub fn with_acceptance(mut self, a: Arc<dyn Acceptance>) -> Self {
896        self.acceptance.push(a);
897        self
898    }
899
900    /// Replace the acceptance set outright (including the default).
901    pub fn with_acceptance_set(mut self, set: Vec<Arc<dyn Acceptance>>) -> Self {
902        self.acceptance = set;
903        self
904    }
905
906    pub fn with_acceptance_retries(mut self, n: u32) -> Self {
907        self.acceptance_retries = n;
908        self
909    }
910
911    pub fn with_tool_timeout(mut self, t: Option<Duration>) -> Self {
912        self.tool_timeout = t;
913        self
914    }
915
916    pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
917        self.compactor = c;
918        // An explicit compactor always wins over the "compactor" model-role
919        // convenience, in either call order.
920        self.compactor_custom = true;
921        self
922    }
923
924    pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
925        self.tools.insert(t);
926        self
927    }
928
929    pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
930        self.guides.push(g);
931        self
932    }
933
934    pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
935        self.sensors.push(s);
936        self
937    }
938
939    pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
940        self.hooks.register(h);
941        self
942    }
943
944    /// Pull in every `#[hook]`-registered hook.
945    pub fn with_macro_hooks(mut self) -> Self {
946        self.hooks = self.hooks.with_macro_hooks_take();
947        self
948    }
949
950    /// Enable cross-session recall: capture every turn into `store` and
951    /// register the `session_search` tool. Owner + session id are read from
952    /// `world.profile.extra["recall_owner"|"recall_session"]` at run time.
953    pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
954        self.tools
955            .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
956        self.recall = Some(store);
957        self
958    }
959
960    /// Capture every turn into `store` **without** registering a search tool.
961    ///
962    /// Ingest and retrieval are separate concerns that [`with_recall`] happens
963    /// to bundle, and the bundling is a trap: capture only ever happens when
964    /// `self.recall` is set, so a host that wants a different search tool — one
965    /// scoped per tenant, or one that copes with a language the backend's index
966    /// does not — has no way to get the writes without also getting
967    /// `session_search`, and ends up offering the model two overlapping tools
968    /// to choose between.
969    ///
970    /// Use this, then register whichever retrieval tool suits the deployment.
971    pub fn with_recall_ingest(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
972        self.recall = Some(store);
973        self
974    }
975
976    /// After `with_recall`, also auto-inject top-k relevant past context at
977    /// session start (off by default — tool-only is prompt-cache friendly).
978    pub fn auto_inject(mut self) -> Self {
979        self.recall_auto_inject = true;
980        self
981    }
982
983    /// Enable the self-evolving learning loop: after a session that made
984    /// `>= cfg.nudge_interval` tool calls, fork a review subagent (white-listed to
985    /// `cfg.tools`) to update skills + memory from the transcript. Best-effort.
986    pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
987        self.learning = Some(cfg);
988        self
989    }
990
991    /// Set the default response format for all runs through this loop. See
992    /// [`ResponseFormat`]. For typed deserialisation, prefer `run_typed::<T>()`.
993    pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
994        self.response_format = fmt;
995        self
996    }
997
998    /// Shortcut for `with_response_format(ResponseFormat::JsonSchema { name, schema })`.
999    /// Accepts a raw `serde_json::Value` so callers can hand-roll the schema or
1000    /// pull it from `schemars::schema_for!(T)`.
1001    pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
1002        self.with_response_format(ResponseFormat::JsonSchema {
1003            name: name.into(),
1004            schema,
1005        })
1006    }
1007
1008    pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
1009        let max = harness_core::Policy::default().max_iters;
1010        self.run_with_max_iters(task, world, max).await
1011    }
1012
1013    /// Run, and hand back the evidence alongside the result.
1014    ///
1015    /// The [`Receipt`] is built here rather than by the caller because the loop
1016    /// already knows the two things a caller would otherwise have to restate —
1017    /// the task and the model — and restating them is how a receipt ends up
1018    /// describing a different run than the one that happened. `now_ms` stays a
1019    /// parameter: this crate does not read the clock, so a receipt is
1020    /// reproducible in a test.
1021    pub async fn run_receipted(
1022        &self,
1023        task: Task,
1024        world: &mut World,
1025        now_ms: i64,
1026    ) -> Result<(Outcome, Receipt), HarnessError> {
1027        let description = task.description.clone();
1028        let handle = self.model.info().handle;
1029        let outcome = self.run(task, world).await?;
1030        let receipt = ReceiptBuilder::new(description, handle, now_ms).build(&outcome);
1031        Ok((outcome, receipt))
1032    }
1033
1034    /// Advance a [`Goal`] by one phase, recording the result durably.
1035    ///
1036    /// Returns `None` when every phase is done — so a resume loop is
1037    /// `while let Some(..) = loop_.run_goal(..).await?`.
1038    ///
1039    /// The phase is marked `Running` and **saved before the model starts**, so
1040    /// a process that dies mid-run leaves a goal that says where it was rather
1041    /// than one that looks untouched. It is saved again afterwards on both
1042    /// paths. That second save on the failure path is the whole reason this
1043    /// method exists: written out by hand at each call site it is four lines,
1044    /// and the failure branch is the one that gets forgotten — which loses
1045    /// exactly the run you most wanted a record of.
1046    pub async fn run_goal(
1047        &self,
1048        goal: &mut Goal,
1049        store: &GoalStore,
1050        world: &mut World,
1051        now_ms: i64,
1052    ) -> Result<Option<(Outcome, Receipt)>, HarnessError> {
1053        let Some(i) = goal.start_current(now_ms) else {
1054            return Ok(None);
1055        };
1056        let _ = store.save(goal);
1057
1058        let task = Task {
1059            description: goal.brief(),
1060            source: None,
1061            deadline: None,
1062        };
1063        let result = self.run_receipted(task, world, now_ms).await;
1064
1065        match &result {
1066            Ok((_, receipt)) => {
1067                if receipt.passed {
1068                    goal.finish(i, receipt.summary(), now_ms);
1069                } else {
1070                    goal.fail(i, receipt.summary(), now_ms);
1071                }
1072            }
1073            // A run that errored outright still happened, and the goal has to
1074            // say so or a resume will retry it as though it were untouched.
1075            Err(e) => goal.fail(i, format!("the run errored: {e}"), now_ms),
1076        }
1077        let _ = store.save(goal);
1078
1079        result.map(Some)
1080    }
1081
1082    pub async fn run_with_max_iters(
1083        &self,
1084        task: Task,
1085        world: &mut World,
1086        max_iters: u32,
1087    ) -> Result<Outcome, HarnessError> {
1088        self.run_with_seed_history(task, Vec::new(), world, max_iters)
1089            .await
1090    }
1091
1092    /// Run the agent and deserialise the terminal reply into `T`.
1093    ///
1094    /// The schema for `T` is derived via `schemars::schema_for!(T)` and
1095    /// installed as `ResponseFormat::JsonSchema` for this run only — any
1096    /// pre-existing `self.response_format` is ignored. On success the
1097    /// returned `T` is parsed from `Outcome::Done.text` (or, on budget
1098    /// exhaustion, from `Outcome::BudgetExhausted.last_text`).
1099    ///
1100    /// Errors:
1101    /// - `HarnessError::Other` if the model returns no text at all
1102    /// - `HarnessError::Other` if `serde_json::from_str::<T>(text)` fails —
1103    ///   the original text is included in the message for debugging.
1104    pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
1105    where
1106        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
1107    {
1108        let max = harness_core::Policy::default().max_iters;
1109        self.run_typed_with_max_iters::<T>(task, world, max).await
1110    }
1111
1112    /// Like `run_typed` but with explicit `max_iters`.
1113    pub async fn run_typed_with_max_iters<T>(
1114        &self,
1115        task: Task,
1116        world: &mut World,
1117        max_iters: u32,
1118    ) -> Result<T, HarnessError>
1119    where
1120        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
1121    {
1122        let schema_root = schemars::schema_for!(T);
1123        let schema = serde_json::to_value(&schema_root)
1124            .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
1125        let name = std::any::type_name::<T>()
1126            .rsplit("::")
1127            .next()
1128            .unwrap_or("response")
1129            .to_string();
1130        let fmt = ResponseFormat::JsonSchema { name, schema };
1131        let outcome = self
1132            .run_with_response_format(task, world, max_iters, fmt)
1133            .await?;
1134        let text = match outcome {
1135            Outcome::Done { text: Some(t), .. }
1136            | Outcome::BudgetExhausted {
1137                last_text: Some(t), ..
1138            }
1139            | Outcome::Stuck {
1140                last_text: Some(t), ..
1141            }
1142            | Outcome::Cancelled {
1143                last_text: Some(t), ..
1144            } => t,
1145            Outcome::Done { text: None, .. } => {
1146                return Err(HarnessError::Other(
1147                    "run_typed: model returned no text".into(),
1148                ));
1149            }
1150            Outcome::Stuck {
1151                last_text: None, ..
1152            } => {
1153                return Err(HarnessError::Other(
1154                    "run_typed: agent stuck with no text".into(),
1155                ));
1156            }
1157            Outcome::BudgetExhausted {
1158                last_text: None, ..
1159            } => {
1160                return Err(HarnessError::Other(
1161                    "run_typed: budget exhausted with no text".into(),
1162                ));
1163            }
1164            Outcome::Cancelled {
1165                last_text: None, ..
1166            } => {
1167                return Err(HarnessError::Other(
1168                    "run_typed: run cancelled with no text".into(),
1169                ));
1170            }
1171        };
1172        serde_json::from_str::<T>(&text).map_err(|e| {
1173            HarnessError::Other(format!(
1174                "run_typed: decode {} failed: {e} — raw text was: {text}",
1175                std::any::type_name::<T>()
1176            ))
1177        })
1178    }
1179
1180    /// Run with a one-off `ResponseFormat` override (doesn't touch `self`).
1181    pub async fn run_with_response_format(
1182        &self,
1183        task: Task,
1184        world: &mut World,
1185        max_iters: u32,
1186        fmt: ResponseFormat,
1187    ) -> Result<Outcome, HarnessError> {
1188        // Borrow checker won't let us swap `self.response_format` because
1189        // `self` is `&`. Easiest workaround: hand-roll the same setup that
1190        // `run_with_seed_history` does, but with our `fmt`. We do this by
1191        // calling through a private helper.
1192        self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
1193            .await
1194    }
1195
1196    async fn run_with_seed_history_and_format(
1197        &self,
1198        task: Task,
1199        seed: Vec<Turn>,
1200        world: &mut World,
1201        max_iters: u32,
1202        fmt_override: Option<ResponseFormat>,
1203    ) -> Result<Outcome, HarnessError> {
1204        let mut ctx = Context::new(task);
1205        ctx.policy.max_iters = max_iters;
1206        ctx.tools = self.tools.schemas();
1207        ctx.history = seed;
1208        ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
1209        self.run_built_context(ctx, world).await
1210    }
1211
1212    /// Like `run_with_max_iters` but seeds `ctx.history` with `seed` **before**
1213    /// the current user task is appended. Use this for multi-turn REPLs so
1214    /// prior conversation lives in `ctx.history` (where the Compactor can see
1215    /// it) instead of being concatenated into `task.description` (where it
1216    /// previously bypassed compaction entirely — see audit #2).
1217    pub async fn run_with_seed_history(
1218        &self,
1219        task: Task,
1220        seed: Vec<Turn>,
1221        world: &mut World,
1222        max_iters: u32,
1223    ) -> Result<Outcome, HarnessError> {
1224        self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
1225            .await
1226    }
1227
1228    /// Like [`run_with_seed_history`](Self::run_with_seed_history) but also seeds
1229    /// `ctx.metadata` with per-request key/values. Hooks and a
1230    /// [`ModelRouter`](harness_models::ModelRouter) read this map — e.g.
1231    /// `audit.actor` / `audit.session` for the audit trail, or
1232    /// `router.keep_local` to pin a request to the local model. This is the
1233    /// entry point a serving layer uses to pass caller identity and routing
1234    /// flags into a single, shared, reused loop.
1235    pub async fn run_with_seed_and_metadata(
1236        &self,
1237        task: Task,
1238        seed: Vec<Turn>,
1239        metadata: std::collections::BTreeMap<String, serde_json::Value>,
1240        world: &mut World,
1241        max_iters: u32,
1242    ) -> Result<Outcome, HarnessError> {
1243        let mut ctx = Context::new(task);
1244        ctx.policy.max_iters = max_iters;
1245        ctx.tools = self.tools.schemas();
1246        ctx.history = seed;
1247        ctx.metadata = metadata;
1248        ctx.response_format = self.response_format.clone();
1249        self.run_built_context(ctx, world).await
1250    }
1251
1252    /// Start a persistent multi-turn [`Session`]. Each `turn` re-runs the loop
1253    /// against the accumulated history with a **stable prefix** (system +
1254    /// name-sorted tool schemas), so a provider's prefix cache (e.g. DeepSeek's,
1255    /// ~10% price on cache-hit tokens) hits across turns instead of paying full
1256    /// price to re-read the same bytes every round. For maximum hit rate, keep
1257    /// your guides' output stable (put per-turn volatile context in the message,
1258    /// not a recomputed system guide).
1259    pub fn session(&self) -> Session<'_, M> {
1260        Session {
1261            loop_: self,
1262            history: Vec::new(),
1263            max_iters: harness_core::Policy::default().max_iters,
1264        }
1265    }
1266
1267    /// Inner ReAct loop on an already-prepared `Context`. Use the public
1268    /// `run*` methods unless you need to inject a non-standard `Context`
1269    /// (e.g. `run_with_response_format` does to apply a one-off
1270    /// `ResponseFormat` without mutating `self`).
1271    ///
1272    /// The run executes inside an `agent_run` span, and every model call and
1273    /// tool call inside a child span of it (`chat`, `execute_tool`). The loop
1274    /// owns these rather than a hook, because only the loop knows when a call
1275    /// really starts and ends: read-only tools are dispatched in parallel
1276    /// *before* their `PreToolUse` fires, so a hook timing them would see a
1277    /// zero-length call. Spans cost nothing without a subscriber; with an OTLP
1278    /// layer (feature `otel`) they are the trace waterfall.
1279    async fn run_built_context(
1280        &self,
1281        ctx: Context,
1282        world: &mut World,
1283    ) -> Result<Outcome, HarnessError> {
1284        let info = self.model.info();
1285        let span = tracing::info_span!(
1286            target: "harness.telemetry",
1287            "agent_run",
1288            "gen_ai.operation.name" = "invoke_agent",
1289            "gen_ai.provider.name" = %info.provider,
1290            "gen_ai.request.model" = %info.model,
1291            outcome = Empty,
1292            "otel.status_code" = Empty,
1293            "otel.status_message" = Empty,
1294        );
1295        let started = std::time::Instant::now();
1296        let r = self.run_in_span(ctx, world).instrument(span.clone()).await;
1297        let outcome = match &r {
1298            Ok(Outcome::Done { .. }) => "done",
1299            Ok(Outcome::BudgetExhausted { .. }) => "budget_exhausted",
1300            Ok(Outcome::Stuck { .. }) => "stuck",
1301            Ok(Outcome::Cancelled { .. }) => "cancelled",
1302            Err(e) => {
1303                span.record("otel.status_code", "error")
1304                    .record("otel.status_message", e.to_string());
1305                "error"
1306            }
1307        };
1308        span.record("outcome", outcome);
1309        meters::run(outcome, started.elapsed());
1310        r
1311    }
1312
1313    async fn run_in_span(
1314        &self,
1315        mut ctx: Context,
1316        world: &mut World,
1317    ) -> Result<Outcome, HarnessError> {
1318        if ctx.system.is_empty() && !self.system.is_empty() {
1319            ctx.system = self.system.clone();
1320        }
1321
1322        // Size the context budget to the model actually in use.
1323        //
1324        // Compaction fires at a fraction of `max_input_tokens`, so a value
1325        // unrelated to the model is wrong in one of two directions: too high and
1326        // the provider rejects the request before the compactor ever runs (a 32k
1327        // model under the 150k default would need 112,500 tokens to trigger, and
1328        // it cannot hold that many); too low and the loop discards history it
1329        // could have kept. Nothing read `ModelInfo::context_window` before this —
1330        // the framework's own defaults disagreed, 150,000 against 128,000.
1331        //
1332        // Only when the caller left the default in place; an explicit policy is
1333        // a decision and stays untouched. The output allowance is reserved,
1334        // because the window is shared between the prompt and the reply.
1335        if let Some(n) = self.max_input_tokens {
1336            // An explicit cap wins over both the default and the model window.
1337            ctx.policy.max_input_tokens = n.max(1);
1338        } else if ctx.policy.max_input_tokens == harness_core::Policy::default().max_input_tokens {
1339            let window = self.model.info().context_window;
1340            if window > 0 {
1341                // Reserve room for the reply, but never let the reservation eat
1342                // the window: an 8k model with the default 8k output allowance
1343                // would leave 0 tokens for input, and every turn — a 26-token
1344                // one included — would run all five compaction stages against a
1345                // budget of 1. Cap the reservation at a quarter of the window.
1346                let reserve = ctx.policy.max_output_tokens.min(window / 4);
1347                ctx.policy.max_input_tokens = window.saturating_sub(reserve).max(1);
1348            }
1349        }
1350
1351        self.hooks.fire(
1352            &Event::SessionStart {
1353                source: SessionSource::Startup,
1354            },
1355            world,
1356        );
1357
1358        // ── recall: resolve owner/session, ensure the session row ──
1359        let (recall_owner, recall_session) = if self.recall.is_some() {
1360            use std::sync::atomic::Ordering;
1361            let owner = crate::recall_owner(world);
1362            let session = world
1363                .profile
1364                .extra
1365                .get("recall_session")
1366                .and_then(|v| v.as_str())
1367                .map(|s| s.to_string())
1368                .unwrap_or_else(|| {
1369                    format!(
1370                        "sess-{}-{}",
1371                        world.clock.now_ms(),
1372                        RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
1373                    )
1374                });
1375            if let Some(store) = &self.recall {
1376                let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
1377                if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
1378                    tracing::warn!(error = %e, "recall ensure_session failed");
1379                }
1380            }
1381            (owner, session)
1382        } else {
1383            (String::new(), String::new())
1384        };
1385
1386        let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
1387            if self.recall.is_none() {
1388                tracing::warn!(
1389                    "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
1390                );
1391                None
1392            } else {
1393                self.recall
1394                    .clone()
1395                    .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
1396            }
1397        } else {
1398            None
1399        };
1400        let all_guides: Vec<&Arc<dyn Guide>> =
1401            self.guides.iter().chain(recall_guide.iter()).collect();
1402        for g in &all_guides {
1403            if g.scope().matches(&ctx.task) {
1404                self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
1405                g.apply(&mut ctx, world).await?;
1406                self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
1407            }
1408        }
1409
1410        ctx.history.push(Turn {
1411            role: TurnRole::User,
1412            blocks: vec![Block::Text(ctx.task.description.clone())],
1413        });
1414
1415        if self.recall.is_some() {
1416            self.recall_append(
1417                &recall_owner,
1418                &recall_session,
1419                harness_core::RecallMessage::new(
1420                    "user",
1421                    ctx.task.description.clone(),
1422                    world.clock.now_ms(),
1423                ),
1424            )
1425            .await;
1426        }
1427
1428        // Running totals — surface to caller even on BudgetExhausted.
1429        let mut tools_called: u32 = 0;
1430        let mut total_usage = harness_core::Usage::default();
1431        let mut last_text: Option<String> = None;
1432
1433        // Stuck-detector state: the previous round's tool-call fingerprint and
1434        // how many consecutive rounds have repeated it.
1435        let mut last_fingerprint: Option<String> = None;
1436        let mut repeat_count: u32 = 0;
1437        // Monotony state: the tool that has been the round's only tool, and for
1438        // how many consecutive rounds. Separate counter from `repeat_count` on
1439        // purpose — the two detectors answer different questions.
1440        let mut monotone_tool: Option<String> = None;
1441        let mut monotone_rounds: u32 = 0;
1442        // Read-only calls already answered this run, cleared whenever anything
1443        // mutates the world. See `ToolResultPolicy::dedupe_repeats`.
1444        let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
1445        // How many times the model has stopped mid-work with nothing to show.
1446        let mut acceptance_retries_left = self.acceptance_retries;
1447
1448        // The contract as it stood before the model touched anything. Taken
1449        // now, not at verdict time, because the whole point is to compare
1450        // against a state the model has not had the chance to influence.
1451        // Set once a sealed file is found to have moved; makes the failure
1452        // terminal and carries the reason out to the caller.
1453        let mut seal_breached: Option<String> = None;
1454        let sealed_before: crate::seal::SealSet = {
1455            let paths: Vec<std::path::PathBuf> =
1456                self.acceptance.iter().flat_map(|a| a.seals()).collect();
1457            if paths.is_empty() {
1458                crate::seal::SealSet::default()
1459            } else {
1460                crate::seal::SealSet::capture(&world.repo.root, paths)
1461            }
1462        };
1463
1464        // How far the loop actually got. Equal to `max_iters` on a normal
1465        // budget exhaustion, less when the wall clock stopped it first.
1466        let mut iters_done = ctx.policy.max_iters;
1467        let mut deadline_reached = false;
1468
1469        for iter in 0..ctx.policy.max_iters {
1470            if self.cancel.is_cancelled() {
1471                tracing::info!(iter, "run cancelled by caller");
1472                self.hooks.fire(&Event::Cancelled, world);
1473                self.hooks.fire(&Event::SessionEnd, world);
1474                return Ok(Outcome::Cancelled {
1475                    iters: iter,
1476                    last_text,
1477                    tools_called,
1478                    usage: total_usage,
1479                });
1480            }
1481
1482            // ── wall-clock budget ───────────────────────────────────────
1483            // `max_iters` bounds *steps*, which says nothing about elapsed
1484            // time: one iteration can be a 100ms read or a 20-minute build.
1485            // An unattended run is bounded in hours, not rounds, so a task
1486            // may carry a deadline — and reaching it goes through the same
1487            // forced-synthesis exit as a spent step budget, so the caller
1488            // gets the conclusion of the work rather than a dropped future.
1489            if let Some(at) = ctx.task.deadline
1490                && world.clock.now_ms() >= at
1491            {
1492                tracing::warn!(
1493                    iter,
1494                    deadline = at,
1495                    "task deadline reached — forcing a final answer"
1496                );
1497                iters_done = iter;
1498                deadline_reached = true;
1499                break;
1500            }
1501            self.hooks.fire(&Event::Heartbeat { iter }, world);
1502
1503            // Compaction with hysteresis: only once over the high-water mark,
1504            // then escalate stage-by-stage, re-estimating after each, and stop
1505            // the moment we're back under target. Avoids over-compacting to the
1506            // lossy AutoCompact stage and avoids rewriting history (→ prefix
1507            // cache miss) every turn. Token estimate is calibrated against the
1508            // last real `input_tokens` via `CALIBRATION_KEY`.
1509            let mut budget = self.compactor.budget(&ctx);
1510            if budget.ratio() > self.compaction.high_water {
1511                for stage in CompactionStage::ALL {
1512                    if budget.ratio() <= self.compaction.target {
1513                        break;
1514                    }
1515                    self.hooks.fire(&Event::PreCompact { stage }, world);
1516                    let before = budget.used;
1517                    let turns_before = ctx.history.len();
1518                    self.compactor.compact(stage, &mut ctx).await?;
1519                    budget = self.compactor.budget(&ctx);
1520                    // Compaction used to leave no trace anywhere: not in the
1521                    // log, not in the transcript. That is a bad property for
1522                    // the one mechanism that rewrites the conversation, and it
1523                    // is how a compaction bug that killed long runs stayed
1524                    // invisible — the only symptom was a provider error with
1525                    // no hint of what had touched the history.
1526                    tracing::info!(
1527                        stage = ?stage,
1528                        tokens_before = before,
1529                        tokens_after = budget.used,
1530                        turns_before,
1531                        turns_after = ctx.history.len(),
1532                        "compacted context"
1533                    );
1534                    meters::compaction(stage, before, budget.used);
1535                    self.hooks.fire(
1536                        &Event::PostCompact {
1537                            stage,
1538                            before,
1539                            after: budget.used,
1540                        },
1541                        world,
1542                    );
1543                }
1544            }
1545
1546            // Per-iteration guides — recall-style adapters that want to
1547            // refresh their injected context every turn (e.g. MemoryGuide
1548            // re-recalling against the latest user message). Default
1549            // `apply_before_iter` is a no-op, so this loop is cheap for
1550            // guides that don't override it.
1551            for g in &all_guides {
1552                if g.scope().matches(&ctx.task)
1553                    && let Err(e) = g.apply_before_iter(&mut ctx, world).await
1554                {
1555                    tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
1556                }
1557            }
1558
1559            self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
1560            let mut partial = String::new();
1561            let Some(out) = self.model_step(&ctx, world, &mut partial).await? else {
1562                tracing::info!(iter, "run cancelled during model step");
1563                // What the stream delivered before the cancel is what the person
1564                // saw; it belongs to the outcome, not to the dropped future.
1565                if !partial.is_empty() {
1566                    last_text = Some(std::mem::take(&mut partial));
1567                }
1568                self.hooks.fire(&Event::Cancelled, world);
1569                self.hooks.fire(&Event::SessionEnd, world);
1570                return Ok(Outcome::Cancelled {
1571                    iters: iter + 1,
1572                    last_text,
1573                    tools_called,
1574                    usage: total_usage,
1575                });
1576            };
1577            self.hooks.fire(&Event::PostModel { out: &out }, world);
1578
1579            // Calibrate the compactor against ground truth: `ctx` still holds
1580            // exactly what we just sent, so `budget().used` is the estimate for
1581            // it. Nudge the stored correction so estimate·correction ≈ the
1582            // model's real `input_tokens` next time. Self-correcting (converges
1583            // even as the raw estimate drifts); clamped so one odd turn can't
1584            // blow it up. This is what makes compaction fire at the right moment
1585            // for *this* model + language instead of a blind char heuristic.
1586            if out.usage.input_tokens > 0 {
1587                let used = self.compactor.budget(&ctx).used;
1588                if used > 0 {
1589                    let prev = ctx
1590                        .metadata
1591                        .get(CALIBRATION_KEY)
1592                        .and_then(|v| v.as_f64())
1593                        .filter(|f| f.is_finite() && *f > 0.0)
1594                        .unwrap_or(1.0);
1595                    let next =
1596                        (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
1597                    ctx.metadata
1598                        .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
1599                }
1600            }
1601
1602            // Accumulate usage even if the run later exhausts budget.
1603            total_usage.input_tokens += out.usage.input_tokens;
1604            total_usage.output_tokens += out.usage.output_tokens;
1605            total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1606            total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
1607            if let Some(t) = &out.text {
1608                last_text = Some(t.clone());
1609            }
1610            ctx.push_model_output(&out);
1611
1612            if self.recall.is_some() {
1613                let calls = if out.tool_calls.is_empty() {
1614                    None
1615                } else {
1616                    serde_json::to_string(&out.tool_calls).ok()
1617                };
1618                let mut m = harness_core::RecallMessage::new(
1619                    "assistant",
1620                    out.text.clone().unwrap_or_default(),
1621                    world.clock.now_ms(),
1622                );
1623                m.tool_calls = calls;
1624                self.recall_append(&recall_owner, &recall_session, m).await;
1625            }
1626
1627            if out.tool_calls.is_empty() {
1628                // The model stopping is its opinion that the work is done.
1629                // Before taking it as fact, run whatever the caller said
1630                // "done" actually means. A failure goes back as an instruction
1631                // and the loop carries on; the retry cap keeps a model that
1632                // ignores corrections from eating the budget.
1633                let mut verdict: Option<Verdict> = None;
1634                if !self.acceptance.is_empty() {
1635                    // Record this turn first — the checks read the transcript.
1636                    let mut probe = ctx.clone();
1637                    probe.history.push(Turn {
1638                        role: TurnRole::Assistant,
1639                        blocks: out
1640                            .text
1641                            .as_deref()
1642                            .filter(|t| !t.trim().is_empty())
1643                            .map(|t| vec![Block::Text(t.to_string())])
1644                            .unwrap_or_default(),
1645                    });
1646                    for check in &self.acceptance {
1647                        let v = check.check(&probe, world).await;
1648                        self.hooks.fire(
1649                            &Event::AcceptanceChecked {
1650                                name: check.name(),
1651                                passed: v.passed,
1652                                reason: &v.reason,
1653                            },
1654                            world,
1655                        );
1656                        if !v.passed {
1657                            tracing::info!(
1658                                check = check.name(),
1659                                reason = %v.reason,
1660                                "acceptance failed"
1661                            );
1662                            verdict = Some(v);
1663                            break;
1664                        }
1665                    }
1666                    // Everything passed. Say so explicitly: "checked, and it
1667                    // holds up" is a different claim from "nobody looked", and
1668                    // the host has to be able to tell them apart.
1669                    verdict = verdict.or_else(|| Some(Verdict::passed()));
1670                }
1671
1672                // A pass is only worth the contract it was measured against.
1673                // Re-read the sealed files and refuse if any moved.
1674                if !sealed_before.is_empty() && verdict.as_ref().is_some_and(|v| v.passed) {
1675                    let paths: Vec<std::path::PathBuf> =
1676                        self.acceptance.iter().flat_map(|a| a.seals()).collect();
1677                    let now = crate::seal::SealSet::capture(&world.repo.root, paths);
1678                    let breaches = sealed_before.breaches(&now);
1679                    if !breaches.is_empty() {
1680                        let what = breaches
1681                            .iter()
1682                            .map(|b| b.describe())
1683                            .collect::<Vec<_>>()
1684                            .join("; ");
1685                        tracing::error!(
1686                            breaches = %what,
1687                            "acceptance contract changed during the run — refusing the pass"
1688                        );
1689                        self.hooks
1690                            .fire(&Event::SealBreached { detail: &what }, world);
1691                        // Deliberately NOT a retry. Every other acceptance
1692                        // failure is handed back as an instruction because the
1693                        // model can act on it; this one would be handing back
1694                        // "you edited the gate", to the party that edited it,
1695                        // with the file still writable. The run is over.
1696                        seal_breached = Some(what);
1697                        verdict = Some(Verdict::failed(
1698                            "the acceptance contract was modified during this run",
1699                        ));
1700                    }
1701                }
1702
1703                if let Some(v) = verdict.clone().filter(|v| !v.passed)
1704                    && seal_breached.is_none()
1705                    && acceptance_retries_left > 0
1706                    && iter + 1 < ctx.policy.max_iters
1707                {
1708                    acceptance_retries_left -= 1;
1709                    ctx.history.push(Turn {
1710                        role: TurnRole::User,
1711                        blocks: vec![Block::Text(v.reason)],
1712                    });
1713                    continue;
1714                }
1715
1716                self.hooks.fire(&Event::TaskCompleted, world);
1717                self.hooks.fire(&Event::SessionEnd, world);
1718                self.run_learning_review(&ctx, world, tools_called).await;
1719                // Thinking models (e.g. Qwen3 via Ollama) sometimes emit the
1720                // whole answer into the reasoning channel and leave `text`
1721                // empty. Fall back to the reasoning so the turn isn't blank —
1722                // but `verified` says whether anyone agreed it was done.
1723                let text = out
1724                    .text
1725                    .filter(|t| !t.trim().is_empty())
1726                    .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
1727                return Ok(Outcome::Done {
1728                    text,
1729                    iters: iter + 1,
1730                    tools_called,
1731                    usage: total_usage,
1732                    verified: verdict,
1733                    contract: sealed_before.clone(),
1734                    seal_breach: seal_breached,
1735                });
1736            }
1737
1738            // Stuck detection runs *after* dispatch — see the block below the
1739            // tool loop, which needs this round's results to judge progress.
1740
1741            // Parallel-safe prefetch: dispatch the *leading run* of read-only
1742            // tool calls concurrently (a mutating tool is a serial barrier).
1743            // The sequential loop below still processes every call in order —
1744            // hooks, sensors, and history stay ordered — only the dispatch IO
1745            // overlaps. Reads before any write are safe; anything at/after the
1746            // first mutating call runs on the normal path.
1747            // Digest of what each tool handed back this round, for the stuck
1748            // check below. Digests rather than the payloads: results are
1749            // already capped, but a per-round copy of every one of them is
1750            // still a cost the detector does not need to pay.
1751            let mut round_results: Vec<u64> = Vec::new();
1752
1753            let mut prefetched: HashMap<String, Dispatched> = HashMap::new();
1754            {
1755                let lead: Vec<&_> = out
1756                    .tool_calls
1757                    .iter()
1758                    .take_while(|c| {
1759                        self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
1760                    })
1761                    .collect();
1762                if lead.len() > 1 {
1763                    let futs = lead.iter().map(|c| {
1764                        let mut w = world.clone();
1765                        let action = Action {
1766                            tool: c.name.clone(),
1767                            call_id: c.id.clone(),
1768                            args: c.args.clone(),
1769                        };
1770                        async move {
1771                            let r = self.dispatch_bounded(&action, &mut w).await;
1772                            (action.call_id, r)
1773                        }
1774                    });
1775                    let results = tokio::select! {
1776                        biased;
1777                        _ = self.cancel.cancelled() => Vec::new(),
1778                        rs = futures::future::join_all(futs) => rs,
1779                    };
1780                    for (id, r) in results {
1781                        prefetched.insert(id, r);
1782                    }
1783                }
1784            }
1785
1786            for call in &out.tool_calls {
1787                let action = Action {
1788                    tool: call.name.clone(),
1789                    call_id: call.id.clone(),
1790                    args: call.args.clone(),
1791                };
1792
1793                // PreToolUse hook can deny destructive actions
1794                if let HookOutcome::Deny { reason } = self
1795                    .hooks
1796                    .fire(&Event::PreToolUse { action: &action }, world)
1797                {
1798                    ctx.history.push(Turn {
1799                        role: TurnRole::Tool,
1800                        blocks: vec![Block::ToolResult {
1801                            call_id: action.call_id.clone(),
1802                            content: serde_json::json!({
1803                                "ok": false,
1804                                "denied_by_hook": reason,
1805                            }),
1806                        }],
1807                    });
1808                    if self.recall.is_some() {
1809                        self.recall_append(
1810                            &recall_owner,
1811                            &recall_session,
1812                            harness_core::RecallMessage::new(
1813                                "tool",
1814                                format!("[denied by hook] {reason}"),
1815                                world.clock.now_ms(),
1816                            )
1817                            .with_tool_name(action.tool.clone()),
1818                        )
1819                        .await;
1820                    }
1821                    continue;
1822                }
1823
1824                // Use the concurrently-prefetched result if we have one;
1825                // otherwise dispatch now.
1826                let Dispatched {
1827                    result,
1828                    started_ms,
1829                    finished_ms,
1830                } = if let Some(r) = prefetched.remove(&action.call_id) {
1831                    r
1832                } else {
1833                    self.dispatch_bounded(&action, world).await
1834                };
1835                tools_called += 1;
1836
1837                if self.cancel.is_cancelled() {
1838                    tracing::info!(iter, "run cancelled during tool dispatch");
1839                    self.hooks.fire(&Event::Cancelled, world);
1840                    self.hooks.fire(&Event::SessionEnd, world);
1841                    return Ok(Outcome::Cancelled {
1842                        iters: iter + 1,
1843                        last_text,
1844                        tools_called,
1845                        usage: total_usage,
1846                    });
1847                }
1848
1849                // Decide the final payload *before* announcing the result, so
1850                // hooks, telemetry and the context all describe the same thing:
1851                // an audit that logs a 200 KB blob the model never saw is not an
1852                // audit of what happened.
1853                let result = ToolResult {
1854                    content: self.shape_result(&action, &result, &mut answered, &world.repo.root),
1855                    ..result
1856                };
1857                self.hooks.fire(
1858                    &Event::ToolTimed {
1859                        action: &action,
1860                        started_ms,
1861                        finished_ms,
1862                    },
1863                    world,
1864                );
1865                self.hooks.fire(
1866                    &Event::PostToolUse {
1867                        action: &action,
1868                        result: &result,
1869                    },
1870                    world,
1871                );
1872
1873                round_results.push(result_digest(&action.tool, &result.content));
1874
1875                ctx.history.push(Turn {
1876                    role: TurnRole::Tool,
1877                    blocks: vec![Block::ToolResult {
1878                        call_id: action.call_id.clone(),
1879                        content: result.content.clone(),
1880                    }],
1881                });
1882
1883                if self.recall.is_some() {
1884                    let body = serde_json::to_string(&result.content).unwrap_or_default();
1885                    self.recall_append(
1886                        &recall_owner,
1887                        &recall_session,
1888                        harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
1889                            .with_tool_name(action.tool.clone()),
1890                    )
1891                    .await;
1892                }
1893
1894                // run self-correct sensors
1895                let mut all_signals = Vec::new();
1896                for s in &self.sensors {
1897                    if s.stage() != Stage::SelfCorrect {
1898                        continue;
1899                    }
1900                    self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
1901                    let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
1902                        tracing::warn!(?e, "sensor failed");
1903                        Vec::new()
1904                    });
1905                    self.hooks.fire(
1906                        &Event::PostSensor {
1907                            sensor: s.id(),
1908                            signals: &sigs,
1909                        },
1910                        world,
1911                    );
1912                    all_signals.extend(sigs);
1913                }
1914                if !all_signals.is_empty() {
1915                    let bundle = SignalSet::new(all_signals);
1916                    let (patches, remaining) = bundle.partition_auto_fix();
1917
1918                    // audit #7: each patch goes through PreAutoFix.
1919                    // Hooks can Deny (skip silently). Default safelist on
1920                    // RunCommand catches the obvious misuses with no hook.
1921                    let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
1922                        if !is_default_safe_fix(p) {
1923                            tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
1924                            self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1925                            return false;
1926                        }
1927                        match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
1928                            HookOutcome::Deny { reason } => {
1929                                tracing::warn!(?p, %reason, "auto-fix denied by hook");
1930                                self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1931                                false
1932                            }
1933                            _ => true,
1934                        }
1935                    }).collect();
1936
1937                    let applied = apply_patches(&approved, world).await;
1938                    // Emit PostAutoFix for each approved patch with the application result.
1939                    for (i, p) in approved.iter().enumerate() {
1940                        self.hooks.fire(
1941                            &Event::PostAutoFix {
1942                                patch: p,
1943                                applied: i < applied.len(),
1944                            },
1945                            world,
1946                        );
1947                    }
1948                    if !applied.is_empty() {
1949                        ctx.push_feedback(vec![harness_core::Signal {
1950                            severity: harness_core::Severity::Hint,
1951                            origin: "auto-fix".into(),
1952                            message: format!(
1953                                "applied {} auto-fix patch(es): {applied:?}",
1954                                applied.len()
1955                            ),
1956                            agent_hint: Some(
1957                                "re-check the affected files before continuing".into(),
1958                            ),
1959                            auto_fix: None,
1960                            location: None,
1961                        }]);
1962                    }
1963                    if remaining.has_blocking() {
1964                        ctx.push_feedback(remaining.signals);
1965                    }
1966                }
1967            }
1968
1969            // ── stuck detection ─────────────────────────────────────────
1970            // A round repeats when the request AND what came back were both
1971            // identical. The same call whose *result moved* is the model
1972            // watching something progress — polling a build, a background
1973            // job, a queue — not spinning, which is why this runs after
1974            // dispatch: before it, this round's results do not exist yet and
1975            // the check could only compare the request. Judging on the
1976            // request alone aborted runs that were making progress, and a
1977            // long unattended task is mostly made of such waits.
1978            //
1979            // A poll whose result never changes still counts, and still
1980            // aborts: burning model calls on a thing that is not moving is
1981            // exactly what this guard is for. What bounds a *legitimate*
1982            // long wait is `max_iters`, not this.
1983            if self.stuck.enabled {
1984                let fp = format!(
1985                    "{}#{}",
1986                    tool_call_fingerprint(&out.tool_calls),
1987                    round_results
1988                        .iter()
1989                        .map(u64::to_string)
1990                        .collect::<Vec<_>>()
1991                        .join(",")
1992                );
1993                if last_fingerprint.as_ref() == Some(&fp) {
1994                    repeat_count += 1;
1995                } else {
1996                    repeat_count = 1;
1997                    last_fingerprint = Some(fp);
1998                }
1999
2000                if repeat_count >= self.stuck.abort_after {
2001                    let reason = format!(
2002                        "repeated the same tool call {repeat_count}× with an unchanging result"
2003                    );
2004                    tracing::warn!(repeated = repeat_count, "stuck: aborting run");
2005                    self.hooks.fire(&Event::SessionEnd, world);
2006                    return Ok(Outcome::Stuck {
2007                        reason,
2008                        repeated: repeat_count,
2009                        iters: iter + 1,
2010                        last_text,
2011                        tools_called,
2012                        usage: total_usage,
2013                    });
2014                }
2015
2016                if repeat_count == self.stuck.nudge_after {
2017                    tracing::warn!(
2018                        repeated = repeat_count,
2019                        "stuck: nudging model to change approach"
2020                    );
2021                    ctx.push_feedback(vec![harness_core::Signal {
2022                        severity: harness_core::Severity::Warn,
2023                        origin: "stuck-detector".into(),
2024                        message: format!(
2025                            "You have issued the same tool call {repeat_count} rounds in a row \
2026                             and the result has not changed."
2027                        ),
2028                        agent_hint: Some(
2029                            "Stop repeating it. Inspect the actual tool result/error, try a \
2030                             different approach, or give your final answer with no tool call. \
2031                             If you are waiting on something, wait inside one call rather than \
2032                             polling in a tight loop."
2033                                .into(),
2034                        ),
2035                        auto_fix: None,
2036                        location: None,
2037                    }]);
2038                }
2039            }
2040
2041            // ── monotony detection ──────────────────────────────────────
2042            // The second signal, and deliberately not part of the block above:
2043            // that one asks "did this round repeat?", this one asks "has the
2044            // model been working a single tool and nothing else?". A guessing
2045            // spiral answers no to the first and yes to the second, which is
2046            // why the byte-identical policy stays exactly as strict as it is.
2047            //
2048            // Off unless the host asked for it — see `MonotonyPolicy` for why
2049            // no threshold is safe by default. Independent of `stuck.enabled`,
2050            // so a host can run either, both, or neither.
2051            if self.monotony.enabled {
2052                match sole_tool(&out.tool_calls) {
2053                    // Same single tool as last round: the run continues.
2054                    Some(name) if monotone_tool.as_deref() == Some(name) => monotone_rounds += 1,
2055                    // A different single tool: a new run of one.
2056                    Some(name) => {
2057                        monotone_tool = Some(name.to_string());
2058                        monotone_rounds = 1;
2059                    }
2060                    // A mixed round. Reaching for a second tool is movement,
2061                    // and movement is what this detector is looking for.
2062                    None => {
2063                        monotone_tool = None;
2064                        monotone_rounds = 0;
2065                    }
2066                }
2067
2068                if monotone_rounds >= self.monotony.abort_after {
2069                    let name = monotone_tool.clone().unwrap_or_default();
2070                    let reason = format!(
2071                        "called `{name}` for {monotone_rounds} consecutive rounds \
2072                         without using any other tool"
2073                    );
2074                    tracing::warn!(tool = %name, rounds = monotone_rounds, "monotony: aborting run");
2075
2076                    // Force the final answer before returning. A model caught
2077                    // here is by definition one that will not stop calling
2078                    // tools on its own, so terminating it without asking for
2079                    // text hands the caller the same empty partial answer the
2080                    // detector exists to prevent — earlier, and therefore
2081                    // worse. Stripping the tools and asking once is the exit
2082                    // the model could not find; it costs one model call and no
2083                    // prompt bytes at all. (The budget-exhausted path has done
2084                    // this since it existed; this is the same exit, reached
2085                    // sooner.)
2086                    match self
2087                        .force_final_synthesis(
2088                            &mut ctx,
2089                            world,
2090                            &mut total_usage,
2091                            MONOTONY_SYNTHESIS_PROMPT,
2092                        )
2093                        .await
2094                    {
2095                        Synthesis::Text(Some(t)) => last_text = Some(t),
2096                        Synthesis::Text(None) => {}
2097                        // Stop means stop: the synthesis is a model call like
2098                        // any other, and the run owes the caller a `Cancelled`
2099                        // rather than the `Stuck` it was on its way to.
2100                        Synthesis::Cancelled => {
2101                            tracing::info!(iter, "run cancelled during final synthesis");
2102                            self.hooks.fire(&Event::Cancelled, world);
2103                            self.hooks.fire(&Event::SessionEnd, world);
2104                            return Ok(Outcome::Cancelled {
2105                                iters: iter + 1,
2106                                last_text,
2107                                tools_called,
2108                                usage: total_usage,
2109                            });
2110                        }
2111                    }
2112
2113                    self.hooks.fire(&Event::SessionEnd, world);
2114                    return Ok(Outcome::Stuck {
2115                        reason,
2116                        repeated: monotone_rounds,
2117                        iters: iter + 1,
2118                        last_text,
2119                        tools_called,
2120                        usage: total_usage,
2121                    });
2122                }
2123
2124                if monotone_rounds == self.monotony.nudge_after {
2125                    let name = monotone_tool.clone().unwrap_or_default();
2126                    tracing::warn!(
2127                        tool = %name,
2128                        rounds = monotone_rounds,
2129                        "monotony: nudging model to change approach"
2130                    );
2131                    ctx.push_feedback(vec![harness_core::Signal {
2132                        severity: harness_core::Severity::Warn,
2133                        origin: "monotony-detector".into(),
2134                        message: format!(
2135                            "You have called `{name}` for {monotone_rounds} rounds in a row \
2136                             and used no other tool. A different argument each time is not \
2137                             the same thing as making progress."
2138                        ),
2139                        agent_hint: Some(
2140                            "If you are guessing at arguments to see which one lands, stop: \
2141                             report what you did find and say plainly what you could not \
2142                             determine. Otherwise reach for a different tool, or give your \
2143                             final answer with no tool call."
2144                                .into(),
2145                        ),
2146                        auto_fix: None,
2147                        location: None,
2148                    }]);
2149                }
2150            }
2151        }
2152        // ── Budget exhausted ─────────────────────────────────────────
2153        // Force a final synthesis pass with tools DISABLED. Otherwise the
2154        // model often spins on tool calls right up to the budget cap and
2155        // never emits a text conclusion, leaving the caller with nothing
2156        // but `last_text` from some earlier intermediate turn (or None).
2157        //
2158        // The synthesis call is "free" — it costs one extra model call
2159        // beyond max_iters but doesn't count toward `iters`. The result
2160        // lands in `last_text` so callers display it as the answer.
2161        //
2162        // Signal to any observer (LiveProgressHook, SessionRecorder, custom
2163        // hooks) that we've used 100% of the budget and are about to force
2164        // synthesis. Pre-existing `BudgetWarning` event was unused; this is
2165        // its natural home.
2166        self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
2167        match self
2168            .force_final_synthesis(&mut ctx, world, &mut total_usage, BUDGET_SYNTHESIS_PROMPT)
2169            .await
2170        {
2171            Synthesis::Text(Some(t)) => last_text = Some(t),
2172            Synthesis::Text(None) => {}
2173            // A cancel that lands here has to win: this is the last model call
2174            // of the run, and it is made after the loop's last cancel check.
2175            // No learning review either — it is one more model call, and the
2176            // other cancel exits all skip it.
2177            Synthesis::Cancelled => {
2178                tracing::info!(iter = iters_done, "run cancelled during final synthesis");
2179                self.hooks.fire(&Event::Cancelled, world);
2180                self.hooks.fire(&Event::SessionEnd, world);
2181                return Ok(Outcome::Cancelled {
2182                    iters: iters_done,
2183                    last_text,
2184                    tools_called,
2185                    usage: total_usage,
2186                });
2187            }
2188        }
2189
2190        self.hooks.fire(&Event::SessionEnd, world);
2191        self.run_learning_review(&ctx, world, tools_called).await;
2192        Ok(Outcome::BudgetExhausted {
2193            iters: iters_done,
2194            last_text,
2195            tools_called,
2196            usage: total_usage,
2197            deadline_reached,
2198        })
2199    }
2200
2201    /// One model call, racing the run's cancellation token.
2202    ///
2203    /// `None` means the token fired first. Dropping the un-awaited future is
2204    /// what cancels the underlying HTTP request (reqwest aborts on drop) or
2205    /// the SSE stream, so the model stops generating rather than finishing
2206    /// into a void. `biased` so a token already cancelled wins a tie.
2207    ///
2208    /// `partial` collects streamed text so a cancel can hand it to the outcome.
2209    async fn model_step(
2210        &self,
2211        ctx: &Context,
2212        world: &mut World,
2213        partial: &mut String,
2214    ) -> Result<Option<ModelOutput>, HarnessError> {
2215        let info = self.model.info();
2216        let span = chat_span(&info);
2217        let started = std::time::Instant::now();
2218        let r = async {
2219            if self.streaming {
2220                tokio::select! {
2221                    biased;
2222                    _ = self.cancel.cancelled() => Ok(None),
2223                    r = self.complete_via_stream(ctx, world, partial) => r.map(Some),
2224                }
2225            } else {
2226                tokio::select! {
2227                    biased;
2228                    _ = self.cancel.cancelled() => Ok(None),
2229                    r = self.model.complete(ctx) => r
2230                        .map(Some)
2231                        .map_err(harness_core::HarnessError::Model),
2232                }
2233            }
2234        }
2235        .instrument(span.clone())
2236        .await;
2237        let end = match &r {
2238            Ok(Some(out)) => Some(Ok(out)),
2239            Ok(None) => None,
2240            Err(e) => Some(Err(e.to_string())),
2241        };
2242        finish_chat(&span, &info, started, end);
2243        r
2244    }
2245
2246    /// Drive `Model::stream()` and assemble the result into a `ModelOutput`,
2247    /// firing `Event::ModelTokenDelta` for each text fragment along the way.
2248    ///
2249    /// Adapters that don't implement real streaming (e.g. `GeminiNative` /
2250    /// `AnthropicNative` today) fall back to the default trait impl, which
2251    /// runs `complete()` and emits the whole reply as a single delta. That
2252    /// works — the loop sees one big `ModelDelta::Text(...)` followed by
2253    /// `Stop`, fires one big `ModelTokenDelta`, and proceeds. So enabling
2254    /// `streaming` is safe regardless of which provider the user picked.
2255    ///
2256    /// `sink` is where the text accumulates, owned by the caller so that a
2257    /// cancel — which drops this future mid-stream — does not drop the chunks
2258    /// already delivered along with it. A completed stream leaves it empty.
2259    ///
2260    /// Tool-call deltas need no such rescue: they fire no hook, and no dispatch
2261    /// happens until after the output is pushed, so a cancel mid-tool-call
2262    /// loses nothing anyone observed.
2263    async fn complete_via_stream(
2264        &self,
2265        ctx: &Context,
2266        world: &mut World,
2267        sink: &mut String,
2268    ) -> Result<ModelOutput, HarnessError> {
2269        use futures::StreamExt;
2270        let mut stream = self
2271            .model
2272            .stream(ctx)
2273            .await
2274            .map_err(harness_core::HarnessError::Model)?;
2275        let mut reasoning = String::new();
2276        let mut usage = Usage::default();
2277        let mut stop_reason = StopReason::EndTurn;
2278        // Insertion-ordered map: index → (id, name, args). We can't use the
2279        // tool-call id as the primary key because the stream may emit args
2280        // chunks before the first chunk that carries the id; the OpenAI-compat
2281        // SSE parser already does its own buffering and surfaces `id` in
2282        // ToolCallStart, but be lenient with adapters that may interleave.
2283        let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
2284        let mut tool_order: Vec<String> = Vec::new();
2285        while let Some(item) = stream.next().await {
2286            let delta = item.map_err(harness_core::HarnessError::Model)?;
2287            match delta {
2288                ModelDelta::Text(t) => {
2289                    if !t.is_empty() {
2290                        self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
2291                        sink.push_str(&t);
2292                    }
2293                }
2294                ModelDelta::ToolCallStart { id, name } => {
2295                    if !tool_starts.contains_key(&id) {
2296                        tool_order.push(id.clone());
2297                    }
2298                    tool_starts
2299                        .entry(id)
2300                        .or_insert_with(|| (name, String::new()));
2301                }
2302                ModelDelta::ToolCallArgs { id, partial_json } => {
2303                    let entry = tool_starts
2304                        .entry(id.clone())
2305                        .or_insert_with(|| (String::new(), String::new()));
2306                    if !tool_order.iter().any(|k| k == &id) {
2307                        tool_order.push(id);
2308                    }
2309                    entry.1.push_str(&partial_json);
2310                }
2311                ModelDelta::ToolCallEnd { .. } => {}
2312                ModelDelta::Usage(u) => usage = u,
2313                ModelDelta::Stop(r) => stop_reason = r,
2314                ModelDelta::Reasoning(s) => {
2315                    // Streamed reasoning arrives as token fragments, not lines —
2316                    // concatenate verbatim (same as `text`), don't insert newlines.
2317                    reasoning.push_str(&s);
2318                }
2319                // ModelDelta is `#[non_exhaustive]`; ignore future variants
2320                // we don't yet understand.
2321                _ => {}
2322            }
2323        }
2324        let tool_calls: Vec<ToolCall> = tool_order
2325            .into_iter()
2326            .filter_map(|id| {
2327                tool_starts.remove(&id).map(|(name, args)| {
2328                    let args_v = serde_json::from_str::<serde_json::Value>(&args)
2329                        .unwrap_or(serde_json::Value::String(args));
2330                    ToolCall {
2331                        id,
2332                        name,
2333                        args: args_v,
2334                    }
2335                })
2336            })
2337            .collect();
2338        // Reconcile stop_reason with what actually came out — adapters
2339        // sometimes emit `Stop(EndTurn)` even after tool_calls, which would
2340        // confuse downstream consumers that branch on stop_reason alone.
2341        let stop_reason = if !tool_calls.is_empty() {
2342            StopReason::ToolUse
2343        } else {
2344            stop_reason
2345        };
2346        let text = std::mem::take(sink);
2347        Ok(ModelOutput {
2348            text: if text.is_empty() { None } else { Some(text) },
2349            tool_calls,
2350            usage,
2351            stop_reason,
2352            reasoning: if reasoning.is_empty() {
2353                None
2354            } else {
2355                Some(reasoning)
2356            },
2357            // `ModelDelta` has no image variant: the verified image-output
2358            // path (Gemini image models over chat) answers non-streamed, so
2359            // there is nothing to accumulate here. A streaming provider that
2360            // emits images would need a `ModelDelta::Image` first.
2361            images: Vec::new(),
2362        })
2363    }
2364
2365    /// One tool call, under the per-call deadline and the run's cancellation
2366    /// token. A timeout becomes an error *result* — the model sees it and routes
2367    /// around it — never a hung run. A cancel drops the tool's future unpolled
2368    /// and returns a result the loop discards before it reaches history, so the
2369    /// model never sees that one at all. Errors are folded the same way: the
2370    /// loop's contract is that a tool call always produces a result turn.
2371    ///
2372    /// Dropping the future stops *this* side of the work. What it stops on the
2373    /// other side depends on the tool: an HTTP request aborts, a `spawn_blocking`
2374    /// file write runs to completion, and a child process is only killed if its
2375    /// runner asked for `kill_on_drop`.
2376    ///
2377    /// Runs in an `execute_tool` span, so parallel calls show up as overlapping
2378    /// bars in a trace and a failed one is marked as an error.
2379    async fn dispatch_bounded(&self, action: &Action, world: &mut World) -> Dispatched {
2380        let span = tracing::info_span!(
2381            target: "harness.telemetry",
2382            "execute_tool",
2383            "otel.name" = %format!("execute_tool {}", action.tool),
2384            "otel.status_code" = Empty,
2385            "otel.status_message" = Empty,
2386            "gen_ai.operation.name" = "execute_tool",
2387            "gen_ai.tool.name" = %action.tool,
2388            "gen_ai.tool.call.id" = %action.call_id,
2389            ok = Empty,
2390        );
2391        let clock = world.clock.clone();
2392        let started_ms = clock.now_ms();
2393        let started = std::time::Instant::now();
2394        let r = self
2395            .dispatch_bounded_in_span(action, world)
2396            .instrument(span.clone())
2397            .await;
2398        let finished_ms = clock.now_ms();
2399        meters::tool_call(&action.tool, r.ok, started.elapsed());
2400        span.record("ok", r.ok);
2401        if !r.ok {
2402            span.record("otel.status_code", "error");
2403            if let Some(msg) = r.content.get("error").and_then(|v| v.as_str()) {
2404                span.record("otel.status_message", msg);
2405            }
2406        }
2407        Dispatched {
2408            result: r,
2409            started_ms,
2410            finished_ms,
2411        }
2412    }
2413
2414    async fn dispatch_bounded_in_span(&self, action: &Action, world: &mut World) -> ToolResult {
2415        let fut = self.tools.dispatch(action, world);
2416        let bounded = async {
2417            match self.tool_timeout {
2418                Some(deadline) => match tokio::time::timeout(deadline, fut).await {
2419                    Ok(r) => r,
2420                    Err(_) => {
2421                        tracing::warn!(
2422                            target: "harness.telemetry",
2423                            event = "tool.deadline",
2424                            "gen_ai.tool.name" = %action.tool,
2425                            seconds = deadline.as_secs(),
2426                        );
2427                        Ok(ToolResult {
2428                            ok: false,
2429                            content: serde_json::json!({
2430                                "error": format!(
2431                                    "tool call exceeded its {}s deadline and was cancelled; \
2432                                     the operation may be too broad — narrow it or try a \
2433                                     different approach",
2434                                    deadline.as_secs()
2435                                ),
2436                                "timeout": true,
2437                            }),
2438                            trace: None,
2439                        })
2440                    }
2441                },
2442                None => fut.await,
2443            }
2444        };
2445        // `biased` so that a token already cancelled wins even when the tool
2446        // future is also ready — the caller said stop, and a result produced
2447        // after that would be work the run is about to throw away anyway.
2448        let dispatched = tokio::select! {
2449            biased;
2450            _ = self.cancel.cancelled() => {
2451                tracing::info!("gen_ai.tool.name" = %action.tool, "tool call dropped: run cancelled");
2452                return ToolResult {
2453                    ok: false,
2454                    content: serde_json::json!({"error": "run cancelled", "cancelled": true}),
2455                    trace: None,
2456                };
2457            }
2458            r = bounded => r,
2459        };
2460        dispatched.unwrap_or_else(|e| ToolResult {
2461            ok: false,
2462            content: serde_json::json!({"error": e.to_string()}),
2463            trace: None,
2464        })
2465    }
2466
2467    /// What a tool result contributes to the context: repeat suppression first,
2468    /// then the size ceiling. A repeat that is also oversized collapses to the
2469    /// pointer rather than to a truncated copy of what the model already holds.
2470    fn shape_result(
2471        &self,
2472        action: &Action,
2473        result: &ToolResult,
2474        answered: &mut std::collections::HashSet<String>,
2475        root: &std::path::Path,
2476    ) -> serde_json::Value {
2477        if !(self.tool_results.dedupe_repeats && result.ok) {
2478            return self.cap_result(action, &result.content, root);
2479        }
2480        match self.tools.risk(&action.tool) {
2481            Some(harness_core::ToolRisk::ReadOnly) => {
2482                let fp = format!("{}({})", action.tool, action.args);
2483                if answered.contains(&fp) {
2484                    tracing::info!(
2485                        target: "harness.telemetry",
2486                        event = "tool.result.repeat",
2487                        "gen_ai.tool.name" = %action.tool,
2488                    );
2489                    serde_json::json!({
2490                        "repeat_of_earlier_call": true,
2491                        "tool": action.tool,
2492                        "note": "You already made this exact call in this run and nothing has \
2493                                 changed the workspace since. The earlier result above still \
2494                                 stands — use it rather than asking again.",
2495                    })
2496                } else {
2497                    answered.insert(fp);
2498                    self.cap_result(action, &result.content, root)
2499                }
2500            }
2501            // A write invalidates every earlier read.
2502            _ => {
2503                answered.clear();
2504                self.cap_result(action, &result.content, root)
2505            }
2506        }
2507    }
2508
2509    /// Enforce [`ToolResultPolicy`] on one result before it reaches the context.
2510    ///
2511    /// Over the ceiling, the guard prefers to *spill*: full payload to a file
2512    /// inside the workspace, bounded preview plus the path inline — nothing is
2513    /// lost, and the model retrieves slices with the file tools it already has.
2514    /// Only when spilling is off (or the write fails) does it fall back to
2515    /// destructive truncation: a byte-level cut handed back as a marker object
2516    /// rather than mangled JSON, saying how much was dropped and what to do
2517    /// instead.
2518    fn cap_result(
2519        &self,
2520        action: &Action,
2521        content: &serde_json::Value,
2522        root: &std::path::Path,
2523    ) -> serde_json::Value {
2524        let Some(max) = self.tool_results.max_bytes else {
2525            return content.clone();
2526        };
2527        let serialized = content.to_string();
2528        if serialized.len() <= max {
2529            return content.clone();
2530        }
2531        if self.tool_results.spill
2532            && let Some(marker) = spill_oversized(action, content, &serialized, root)
2533        {
2534            return marker;
2535        }
2536        // Cut on a char boundary so the kept head is valid UTF-8.
2537        let mut end = max;
2538        while end > 0 && !serialized.is_char_boundary(end) {
2539            end -= 1;
2540        }
2541        tracing::warn!(
2542            target: "harness.telemetry",
2543            event = "tool.result.truncated",
2544            "gen_ai.tool.name" = %action.tool,
2545            bytes = serialized.len(),
2546            max_bytes = max,
2547        );
2548        serde_json::json!({
2549            "truncated": true,
2550            "tool": action.tool,
2551            "bytes_total": serialized.len(),
2552            "bytes_kept": end,
2553            "head": serialized[..end],
2554            "note": format!(
2555                "This result was {} bytes and was cut to {} to protect the context window. \
2556                 Do not ask for it again unchanged — narrow it: request a smaller range, \
2557                 a filter, or a specific field.",
2558                serialized.len(), end
2559            ),
2560        })
2561    }
2562
2563    async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
2564        if let Some(store) = &self.recall
2565            && let Err(e) = store.append(owner, session, &msg).await
2566        {
2567            tracing::warn!(error = %e, "recall append failed");
2568        }
2569    }
2570
2571    /// Best-effort post-session review. Never affects the finished run.
2572    async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
2573        let Some(cfg) = &self.learning else { return };
2574        if tools_called < cfg.nudge_interval {
2575            return;
2576        }
2577        let transcript = crate::render_transcript(&ctx.history, 12_000);
2578        let task = harness_core::Task {
2579            description: format!(
2580                "{}\n\n## Conversation transcript\n{}",
2581                cfg.review_prompt, transcript
2582            ),
2583            source: None,
2584            deadline: None,
2585        };
2586        let mut spec =
2587            crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
2588        for t in &cfg.tools {
2589            spec = spec.with_tool(t.clone());
2590        }
2591        let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
2592        // Box::pin breaks the recursive async-future cycle: AgentLoop<M> →
2593        // run_learning_review → Subagent<DynModel>::run →
2594        // AgentLoop<Arc<dyn Model>>::run_built_context. Without pinning the
2595        // compiler rejects the infinite-sized future.
2596        if let Err(e) = Box::pin(sub.run(world)).await {
2597            tracing::warn!(error = %e, "learning review failed");
2598        }
2599    }
2600
2601    /// One final model call with tools removed, asking it to write the
2602    /// best-effort conclusion from whatever it has already gathered.
2603    ///
2604    /// Errors from the model are swallowed — observability is best-effort
2605    /// here, and a transport blip during synthesis should not turn a
2606    /// near-complete run into a hard failure.
2607    ///
2608    /// The call is raced against the run's cancellation token like every
2609    /// other model call: a cancel before or during it produces no text,
2610    /// leaves the context as it was, and is reported as
2611    /// [`Synthesis::Cancelled`] so the caller can return `Outcome::Cancelled`
2612    /// instead of pretending the run ended on its own terms.
2613    ///
2614    /// `prompt` says *why* the loop stopped granting tool calls. Budget
2615    /// exhaustion is not the only way a run can end with the model still
2616    /// reaching for tools — a detected loop is another — and the model writes a
2617    /// better final answer when it is told which one happened.
2618    async fn force_final_synthesis(
2619        &self,
2620        ctx: &mut Context,
2621        world: &mut World,
2622        total_usage: &mut harness_core::Usage,
2623        prompt: &str,
2624    ) -> Synthesis {
2625        // Snapshot + clear tool schemas so the model has no choice but text.
2626        let saved_tools = std::mem::take(&mut ctx.tools);
2627        ctx.history.push(Turn {
2628            role: TurnRole::User,
2629            blocks: vec![Block::Text(prompt.into())],
2630        });
2631
2632        self.hooks.fire(&Event::PreModel { ctx }, world);
2633        // `biased` with the token first means an already-cancelled token wins
2634        // the race outright, so no separate `is_cancelled()` pre-check is
2635        // needed here — the select is the check.
2636        let info = self.model.info();
2637        let span = chat_span(&info);
2638        let started = std::time::Instant::now();
2639        let result = tokio::select! {
2640            biased;
2641            _ = self.cancel.cancelled() => None,
2642            r = self.model.complete(ctx).instrument(span.clone()) => Some(r),
2643        };
2644        finish_chat(
2645            &span,
2646            &info,
2647            started,
2648            result
2649                .as_ref()
2650                .map(|r| r.as_ref().map_err(|e| e.to_string())),
2651        );
2652        ctx.tools = saved_tools;
2653
2654        let Some(result) = result else {
2655            // Drop the synthesis prompt pushed above. Nothing answered it, and
2656            // leaving it would hand the next reader of this context a dangling
2657            // user turn.
2658            ctx.history.pop();
2659            return Synthesis::Cancelled;
2660        };
2661
2662        match result {
2663            Ok(out) => {
2664                self.hooks.fire(&Event::PostModel { out: &out }, world);
2665                total_usage.input_tokens += out.usage.input_tokens;
2666                total_usage.output_tokens += out.usage.output_tokens;
2667                total_usage.cached_input_tokens += out.usage.cached_input_tokens;
2668                total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
2669                ctx.push_model_output(&out);
2670                Synthesis::Text(out.text)
2671            }
2672            Err(_) => Synthesis::Text(None),
2673        }
2674    }
2675}
2676
2677/// What the forced final synthesis produced.
2678enum Synthesis {
2679    /// The model answered (or errored — errors are swallowed, see the fn doc).
2680    Text(Option<String>),
2681    /// The run's token fired before or during the call; no text was produced
2682    /// and nothing was pushed to the context.
2683    Cancelled,
2684}
2685
2686/// A persistent multi-turn conversation over one [`AgentLoop`].
2687///
2688/// Holds the append-only history and, on each [`turn`](Session::turn), re-runs
2689/// the loop against a **stable prefix** (system + name-sorted tool schemas).
2690/// That byte-stable prefix is what lets a provider's prefix cache hit across
2691/// turns — the difference between paying full price to re-read the same context
2692/// every round and paying ~10% for the cached bytes (DeepSeek).
2693pub struct Session<'a, M: Model> {
2694    loop_: &'a AgentLoop<M>,
2695    history: Vec<Turn>,
2696    max_iters: u32,
2697}
2698
2699impl<'a, M: Model> Session<'a, M> {
2700    pub fn with_max_iters(mut self, n: u32) -> Self {
2701        self.max_iters = n;
2702        self
2703    }
2704    /// Preload prior turns (e.g. resumed from disk).
2705    pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
2706        self.history = seed;
2707        self
2708    }
2709    /// The accumulated conversation so far.
2710    pub fn history(&self) -> &[Turn] {
2711        &self.history
2712    }
2713    /// Start over (branch): drop the accumulated turns.
2714    pub fn reset(&mut self) {
2715        self.history.clear();
2716    }
2717
2718    /// Send one user message. Runs the ReAct loop against the accumulated
2719    /// history, then appends this user turn + the assistant reply so the next
2720    /// turn extends the same cached prefix.
2721    pub async fn turn(
2722        &mut self,
2723        message: impl Into<String>,
2724        world: &mut World,
2725    ) -> Result<Outcome, HarnessError> {
2726        let message = message.into();
2727        let task = Task {
2728            description: message.clone(),
2729            source: None,
2730            deadline: None,
2731        };
2732        let outcome = self
2733            .loop_
2734            .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
2735            .await?;
2736        let reply = match &outcome {
2737            Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
2738            Outcome::BudgetExhausted { last_text, .. }
2739            | Outcome::Stuck { last_text, .. }
2740            | Outcome::Cancelled { last_text, .. } => last_text.clone().unwrap_or_default(),
2741        };
2742        self.history.push(Turn {
2743            role: TurnRole::User,
2744            blocks: vec![Block::Text(message)],
2745        });
2746        self.history.push(Turn {
2747            role: TurnRole::Assistant,
2748            blocks: vec![Block::Text(reply)],
2749        });
2750        Ok(outcome)
2751    }
2752}
2753
2754/// Audit #7: default safelist for `FixPatch::RunCommand`.
2755///
2756/// Sensors emitting `RunCommand` patches would otherwise be a silent
2757/// arbitrary-code-execution channel. We restrict the *program* by name to a
2758/// short list of well-known, side-effect-bounded formatters/fixers. Anything
2759/// else returns false and the patch is rejected (write your own `PreAutoFix`
2760/// hook returning `HookOutcome::Allow` to widen the policy).
2761///
2762/// `ReplaceFile` and `UnifiedDiff` are not restricted here — they only touch
2763/// files inside the workspace and are covered by the symlink-safe path
2764/// resolution in `harness-tools-fs`.
2765pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
2766    use harness_core::FixPatch;
2767    match patch {
2768        FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
2769        FixPatch::RunCommand { program, args, .. } => match program.as_str() {
2770            // Cargo subcommands proven side-effect-bounded.
2771            "cargo" => matches!(
2772                args.first().map(String::as_str),
2773                Some("fmt" | "clippy" | "fix"),
2774            ),
2775            "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
2776            _ => false,
2777        },
2778        // Future FixPatch variants: deny by default — review and add to the list above.
2779        _ => false,
2780    }
2781}
2782
2783/// Monotonic counter for `.harness-patch-*.diff` temp filenames — millisecond
2784/// resolution alone collides under parallel agent runs.
2785static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2786
2787/// Monotonic counter for fallback recall session ids (no `uuid` dep).
2788static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2789
2790/// Apply auto-fix patches; return short descriptions of those that succeeded.
2791///
2792/// Made `pub` (was `pub(crate)`) so integration tests can call it directly.
2793pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
2794    use harness_core::FixPatch;
2795    let mut applied = Vec::new();
2796    for p in patches {
2797        match p {
2798            FixPatch::ReplaceFile { path, content } => {
2799                let abs = world.repo.root.join(path);
2800                if let Some(parent) = abs.parent() {
2801                    let _ = tokio::fs::create_dir_all(parent).await;
2802                }
2803                if tokio::fs::write(&abs, content).await.is_ok() {
2804                    applied.push(format!("replaced {}", path.display()));
2805                }
2806            }
2807            FixPatch::UnifiedDiff { diff } => {
2808                if try_apply_diff(world, diff).await {
2809                    applied.push("unified diff applied".into());
2810                }
2811            }
2812            FixPatch::RunCommand { program, args, cwd } => {
2813                let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
2814                let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
2815                if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
2816                    && out.status == 0
2817                {
2818                    applied.push(format!("ran `{program} {}`", args.join(" ")));
2819                }
2820            }
2821            // FixPatch is `#[non_exhaustive]`; unknown variants are skipped.
2822            _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
2823        }
2824    }
2825    applied
2826}
2827
2828/// Write `diff` to a unique temp file and try `patch -p1` first, then `-p0`.
2829/// Returns whether either succeeded. The `-p1`-then-`-p0` order matches the
2830/// reality that most agent-emitted diffs are git-style (need `-p1`) but some
2831/// hand-rolled diffs use repo-relative paths (need `-p0`).
2832async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
2833    use std::sync::atomic::Ordering;
2834    use tokio::io::AsyncWriteExt;
2835
2836    let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
2837    let pid = std::process::id();
2838    let now = world.clock.now_ms();
2839    let tmp = world
2840        .repo
2841        .root
2842        .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
2843
2844    let mut f = match tokio::fs::File::create(&tmp).await {
2845        Ok(f) => f,
2846        Err(e) => {
2847            tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
2848            return false;
2849        }
2850    };
2851    if let Err(e) = f.write_all(diff.as_bytes()).await {
2852        tracing::warn!(error=%e, "could not write patch tempfile");
2853        let _ = tokio::fs::remove_file(&tmp).await;
2854        return false;
2855    }
2856    drop(f);
2857
2858    let tmp_str = tmp.to_string_lossy().to_string();
2859    let mut applied = false;
2860    for strip in ["-p1", "-p0"] {
2861        match world
2862            .runner
2863            .exec(
2864                "patch",
2865                &[strip, "--silent", "-i", tmp_str.as_str()],
2866                Some(world.repo.root.as_path()),
2867            )
2868            .await
2869        {
2870            Ok(out) if out.status == 0 => {
2871                tracing::info!(strip, "patch applied");
2872                applied = true;
2873                break;
2874            }
2875            Ok(out) => {
2876                tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
2877            }
2878            Err(e) => {
2879                tracing::warn!(error=%e, "patch command not available");
2880                break; // patch tool missing — no point trying other strip
2881            }
2882        }
2883    }
2884    let _ = tokio::fs::remove_file(&tmp).await;
2885    applied
2886}