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