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