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