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