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 learning;
13pub mod memory_layer;
14#[cfg(feature = "otel")]
15pub mod otel;
16pub mod profile_guide;
17pub mod recall_layer;
18pub mod registry;
19pub use acceptance::{Acceptance, FilesExist, NonEmptyAnswer, Verdict};
20pub mod replay;
21pub mod subagent;
22pub mod telemetry;
23
24pub use learning::*;
25pub use memory_layer::*;
26pub mod boundary_guide;
27pub use boundary_guide::*;
28pub use profile_guide::*;
29pub use recall_layer::*;
30pub use registry::*;
31pub use replay::*;
32pub use subagent::*;
33pub use telemetry::*;
34
35use harness_compactor::{CALIBRATION_KEY, DefaultCompactor};
36use harness_core::{
37 Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
38 Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
39 StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
40};
41use harness_hooks::HookBus;
42use std::collections::HashMap;
43use std::sync::Arc;
44
45/// Governs the loop's stuck-detector. When the model repeats the *same* tool
46/// call (name + args) round after round without making progress, the loop first
47/// nudges it to change approach, then terminates cleanly with [`Outcome::Stuck`]
48/// rather than burning the rest of the budget spinning on a loop.
49///
50/// Enabled by default with conservative thresholds — the calls must be
51/// *byte-identical*, so genuine "read the same file twice" work never trips it.
52#[derive(Debug, Clone)]
53pub struct StuckPolicy {
54 pub enabled: bool,
55 /// Consecutive identical tool-call rounds before injecting a "you are
56 /// repeating yourself, change your approach" feedback signal.
57 pub nudge_after: u32,
58 /// Consecutive identical rounds before terminating with [`Outcome::Stuck`].
59 pub abort_after: u32,
60}
61
62impl Default for StuckPolicy {
63 fn default() -> Self {
64 Self {
65 enabled: true,
66 nudge_after: 3,
67 abort_after: 6,
68 }
69 }
70}
71
72/// A ceiling on how much of one tool result reaches the context.
73///
74/// A single call can return more than the whole conversation: a lock file, a
75/// `SELECT *`, an MCP tool the framework does not control. Measured on a real
76/// run, "search these files for a word" cost 53,487 input tokens because one
77/// `read_file` returned a lock file — the model then paid for it on every
78/// subsequent turn, and compaction started throwing away real history to make
79/// room. A per-result ceiling is the only place to stop that: the tools cannot
80/// all be trusted (third-party MCP), and the compactor only runs after the
81/// damage is in the context.
82#[derive(Debug, Clone)]
83pub struct ToolResultPolicy {
84 /// Max serialized bytes of a single tool result. `None` disables the guard.
85 /// Default ~24 KiB — roughly 6k tokens of English, generous for a file page
86 /// or a query result, far below what blows a window.
87 pub max_bytes: Option<usize>,
88 /// Replace the payload of a read-only call that exactly repeats an earlier
89 /// one in the same run, when nothing has modified the world in between.
90 ///
91 /// [`StuckPolicy`] only sees *consecutive* identical rounds. Reading a file
92 /// at iteration 1 and again at iteration 5 is not that, and looks like
93 /// progress — but the same bytes land in the context twice and the model
94 /// learns nothing the second time. Measured on a real run, model wait was
95 /// 36.7s against 3ms of tool execution: a repeat costs context, not time,
96 /// so what is suppressed is the payload, not the call.
97 ///
98 /// Only `ToolRisk::ReadOnly` qualifies (`Network` is a separate risk, and an
99 /// external endpoint may answer differently), and any non-read-only call
100 /// clears the record — after a write, re-reading is the correct move.
101 pub dedupe_repeats: bool,
102}
103
104impl Default for ToolResultPolicy {
105 fn default() -> Self {
106 Self {
107 max_bytes: Some(24 * 1024),
108 dedupe_repeats: true,
109 }
110 }
111}
112
113/// Governs *when* and *how far* the loop compacts context. Hysteresis: only
114/// start compacting once usage crosses `high_water`, and stop as soon as it's
115/// back under `target` — instead of running every stage above a threshold on
116/// every turn. This avoids over-compacting (needlessly reaching the lossy,
117/// main-model `AutoCompact` stage) and, because compaction rewrites history and
118/// invalidates the provider prefix cache, avoids nibbling the context every
119/// single turn.
120#[derive(Debug, Clone)]
121pub struct CompactPolicy {
122 /// Start compacting when `used/window` exceeds this. Default 0.75.
123 pub high_water: f32,
124 /// Stop as soon as `used/window` is back at/under this. Default 0.55.
125 pub target: f32,
126}
127
128impl Default for CompactPolicy {
129 fn default() -> Self {
130 Self {
131 high_water: 0.75,
132 target: 0.55,
133 }
134 }
135}
136
137/// A stable fingerprint of a round's tool calls (names + args, ignoring the
138/// volatile call id). Two rounds with the same fingerprint asked for the exact
139/// same actions — the signal the stuck-detector keys on.
140fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
141 calls
142 .iter()
143 .map(|c| format!("{}({})", c.name, c.args))
144 .collect::<Vec<_>>()
145 .join("|")
146}
147
148/// Where a run finished. Each variant is `#[non_exhaustive]` so new *fields*
149/// don't break downstream matches — always include `..` when destructuring.
150#[derive(Debug, Clone)]
151pub enum Outcome {
152 /// Model returned text with no tool calls (natural end).
153 #[non_exhaustive]
154 Done {
155 text: Option<String>,
156 iters: u32,
157 tools_called: u32,
158 usage: harness_core::Usage,
159 /// What the acceptance checks said. `None` means nothing was asked —
160 /// so "the model stopped" is all this outcome claims.
161 verified: Option<Verdict>,
162 },
163 /// Policy budget exhausted before the model stopped requesting tools.
164 /// Carries everything we know so the caller can recover partial work
165 /// (saved notes, files written by tools, the last assistant text, etc.)
166 /// instead of seeing a single bare "budget out" string.
167 #[non_exhaustive]
168 BudgetExhausted {
169 iters: u32,
170 last_text: Option<String>,
171 tools_called: u32,
172 usage: harness_core::Usage,
173 },
174 /// The agent got stuck: it repeated the *same* tool call for
175 /// `StuckPolicy::abort_after` consecutive rounds without progress, so the
176 /// loop terminated early to save the rest of the budget. Carries partial
177 /// work (last text, files already written by tools) like `BudgetExhausted`.
178 #[non_exhaustive]
179 Stuck {
180 /// Human-readable reason, e.g. "repeated `read_file(...)` 6× without progress".
181 reason: String,
182 /// How many consecutive identical rounds were observed.
183 repeated: u32,
184 iters: u32,
185 last_text: Option<String>,
186 tools_called: u32,
187 usage: harness_core::Usage,
188 },
189}
190
191/// The agent loop.
192pub struct AgentLoop<M: Model> {
193 pub model: M,
194 pub tools: ToolRegistry,
195 pub guides: Vec<Arc<dyn Guide>>,
196 pub sensors: Vec<Arc<dyn Sensor>>,
197 pub hooks: HookBus,
198 pub compactor: Arc<dyn Compactor>,
199 /// Default response format applied to every run unless overridden by
200 /// `run_typed`. See [`ResponseFormat`].
201 pub response_format: ResponseFormat,
202 /// When `true`, the loop drives each model turn via `Model::stream()`
203 /// instead of `complete()`, firing `Event::ModelTokenDelta` for each
204 /// text fragment. Tool-call deltas are still assembled inside the loop;
205 /// only the terminal `ModelOutput` shape is observable downstream.
206 pub streaming: bool,
207 /// Optional cross-session recall store. When set, the loop captures every
208 /// turn and the `session_search` tool is registered. See `with_recall`.
209 pub recall: Option<Arc<dyn harness_core::RecallStore>>,
210 /// When true (and `recall` is set), a `RecallGuide` auto-injects top-k
211 /// past context at session start.
212 pub recall_auto_inject: bool,
213 pub learning: Option<LearningConfig>,
214 /// Loop-detection policy. Enabled by default — see [`StuckPolicy`].
215 pub stuck: StuckPolicy,
216 /// Context-compaction hysteresis. See [`CompactPolicy`].
217 pub compaction: CompactPolicy,
218 /// Ceiling on a single tool result. See [`ToolResultPolicy`].
219 pub tool_results: ToolResultPolicy,
220 /// Conditions the run must satisfy before the loop reports success. The
221 /// model stopping is evidence it *believes* it is finished; these say
222 /// whether it is. See [`acceptance`].
223 pub acceptance: Vec<Arc<dyn Acceptance>>,
224 /// How many times a failed acceptance is handed back to the model before
225 /// the loop gives up and reports what there is. One is usually enough: a
226 /// model that ignores the first correction rarely takes the second.
227 pub acceptance_retries: u32,
228 /// System instruction injected into every run's `Context.system` (unless the
229 /// built context already carries its own). Set via [`with_system`](Self::with_system).
230 pub system: Vec<Block>,
231}
232
233impl AgentLoop<harness_core::DynModel> {
234 /// Build a loop from a boxed model — what every model factory hands back
235 /// (`ApiKind::build`, a router, anything stored behind a trait object).
236 ///
237 /// `Arc<dyn Model>` deliberately does not implement `Model` (see
238 /// [`DynModel`](harness_core::DynModel) for why), so `AgentLoop::new` cannot
239 /// take one. Without this constructor every caller writes the wrapper
240 /// themselves, and the first thing a new user meets is a trait-bound error
241 /// naming a type they have never heard of.
242 ///
243 /// ```ignore
244 /// let model = ApiKind::OpenAI.build(base_url, model_id, key);
245 /// let agent = AgentLoop::boxed(model).with_tool(Arc::new(ReadFile));
246 /// ```
247 pub fn boxed(model: Arc<dyn Model>) -> Self {
248 Self::new(harness_core::DynModel(model))
249 }
250}
251
252impl<M: Model> AgentLoop<M> {
253 pub fn new(model: M) -> Self {
254 Self {
255 model,
256 tools: ToolRegistry::new(),
257 guides: Vec::new(),
258 sensors: Vec::new(),
259 hooks: HookBus::new(),
260 compactor: Arc::new(DefaultCompactor::new()),
261 response_format: ResponseFormat::Free,
262 streaming: false,
263 recall: None,
264 recall_auto_inject: false,
265 learning: None,
266 stuck: StuckPolicy::default(),
267 compaction: CompactPolicy::default(),
268 // On by default, because the failure it catches is invisible: a
269 // turn that produced nothing is reported as a turn that finished.
270 tool_results: ToolResultPolicy::default(),
271 acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
272 acceptance_retries: 1,
273 system: Vec::new(),
274 }
275 }
276
277 /// Set a system instruction applied to every run (into `Context.system`) —
278 /// e.g. "answer only via the governed tools; never claim you can't access
279 /// data; never invent numbers". This is the first-class seam for a system
280 /// prompt; small local models in particular need it to reliably call tools
281 /// instead of refusing or hallucinating.
282 pub fn with_system(mut self, text: impl Into<String>) -> Self {
283 self.system = vec![Block::Text(text.into())];
284 self
285 }
286
287 /// Override the loop-detection policy (thresholds, or disable entirely).
288 pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
289 self.stuck = policy;
290 self
291 }
292
293 /// Override the compaction hysteresis policy. See [`CompactPolicy`].
294 /// Set the ceiling on a single tool result. See [`ToolResultPolicy`].
295 pub fn with_tool_result_policy(mut self, policy: ToolResultPolicy) -> Self {
296 self.tool_results = policy;
297 self
298 }
299
300 pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
301 self.compaction = policy;
302 self
303 }
304
305 /// Opt in to streaming the model's terminal turn token-by-token via
306 /// `Model::stream()`. Hooks subscribed to `Event::ModelTokenDelta` see
307 /// each fragment as it arrives; the rest of the loop is unchanged.
308 pub fn with_streaming(mut self, enable: bool) -> Self {
309 self.streaming = enable;
310 self
311 }
312
313 /// Add a condition the run must satisfy before it can report success.
314 pub fn with_acceptance(mut self, a: Arc<dyn Acceptance>) -> Self {
315 self.acceptance.push(a);
316 self
317 }
318
319 /// Replace the acceptance set outright (including the default).
320 pub fn with_acceptance_set(mut self, set: Vec<Arc<dyn Acceptance>>) -> Self {
321 self.acceptance = set;
322 self
323 }
324
325 pub fn with_acceptance_retries(mut self, n: u32) -> Self {
326 self.acceptance_retries = n;
327 self
328 }
329
330 pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
331 self.compactor = c;
332 self
333 }
334
335 pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
336 self.tools.insert(t);
337 self
338 }
339
340 pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
341 self.guides.push(g);
342 self
343 }
344
345 pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
346 self.sensors.push(s);
347 self
348 }
349
350 pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
351 self.hooks.register(h);
352 self
353 }
354
355 /// Pull in every `#[hook]`-registered hook.
356 pub fn with_macro_hooks(mut self) -> Self {
357 self.hooks = self.hooks.with_macro_hooks_take();
358 self
359 }
360
361 /// Enable cross-session recall: capture every turn into `store` and
362 /// register the `session_search` tool. Owner + session id are read from
363 /// `world.profile.extra["recall_owner"|"recall_session"]` at run time.
364 pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
365 self.tools
366 .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
367 self.recall = Some(store);
368 self
369 }
370
371 /// After `with_recall`, also auto-inject top-k relevant past context at
372 /// session start (off by default — tool-only is prompt-cache friendly).
373 pub fn auto_inject(mut self) -> Self {
374 self.recall_auto_inject = true;
375 self
376 }
377
378 /// Enable the self-evolving learning loop: after a session that made
379 /// `>= cfg.nudge_interval` tool calls, fork a review subagent (white-listed to
380 /// `cfg.tools`) to update skills + memory from the transcript. Best-effort.
381 pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
382 self.learning = Some(cfg);
383 self
384 }
385
386 /// Set the default response format for all runs through this loop. See
387 /// [`ResponseFormat`]. For typed deserialisation, prefer `run_typed::<T>()`.
388 pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
389 self.response_format = fmt;
390 self
391 }
392
393 /// Shortcut for `with_response_format(ResponseFormat::JsonSchema { name, schema })`.
394 /// Accepts a raw `serde_json::Value` so callers can hand-roll the schema or
395 /// pull it from `schemars::schema_for!(T)`.
396 pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
397 self.with_response_format(ResponseFormat::JsonSchema {
398 name: name.into(),
399 schema,
400 })
401 }
402
403 pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
404 let max = harness_core::Policy::default().max_iters;
405 self.run_with_max_iters(task, world, max).await
406 }
407
408 pub async fn run_with_max_iters(
409 &self,
410 task: Task,
411 world: &mut World,
412 max_iters: u32,
413 ) -> Result<Outcome, HarnessError> {
414 self.run_with_seed_history(task, Vec::new(), world, max_iters)
415 .await
416 }
417
418 /// Run the agent and deserialise the terminal reply into `T`.
419 ///
420 /// The schema for `T` is derived via `schemars::schema_for!(T)` and
421 /// installed as `ResponseFormat::JsonSchema` for this run only — any
422 /// pre-existing `self.response_format` is ignored. On success the
423 /// returned `T` is parsed from `Outcome::Done.text` (or, on budget
424 /// exhaustion, from `Outcome::BudgetExhausted.last_text`).
425 ///
426 /// Errors:
427 /// - `HarnessError::Other` if the model returns no text at all
428 /// - `HarnessError::Other` if `serde_json::from_str::<T>(text)` fails —
429 /// the original text is included in the message for debugging.
430 pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
431 where
432 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
433 {
434 let max = harness_core::Policy::default().max_iters;
435 self.run_typed_with_max_iters::<T>(task, world, max).await
436 }
437
438 /// Like `run_typed` but with explicit `max_iters`.
439 pub async fn run_typed_with_max_iters<T>(
440 &self,
441 task: Task,
442 world: &mut World,
443 max_iters: u32,
444 ) -> Result<T, HarnessError>
445 where
446 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
447 {
448 let schema_root = schemars::schema_for!(T);
449 let schema = serde_json::to_value(&schema_root)
450 .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
451 let name = std::any::type_name::<T>()
452 .rsplit("::")
453 .next()
454 .unwrap_or("response")
455 .to_string();
456 let fmt = ResponseFormat::JsonSchema { name, schema };
457 let outcome = self
458 .run_with_response_format(task, world, max_iters, fmt)
459 .await?;
460 let text = match outcome {
461 Outcome::Done { text: Some(t), .. }
462 | Outcome::BudgetExhausted {
463 last_text: Some(t), ..
464 }
465 | Outcome::Stuck {
466 last_text: Some(t), ..
467 } => t,
468 Outcome::Done { text: None, .. } => {
469 return Err(HarnessError::Other(
470 "run_typed: model returned no text".into(),
471 ));
472 }
473 Outcome::Stuck {
474 last_text: None, ..
475 } => {
476 return Err(HarnessError::Other(
477 "run_typed: agent stuck with no text".into(),
478 ));
479 }
480 Outcome::BudgetExhausted {
481 last_text: None, ..
482 } => {
483 return Err(HarnessError::Other(
484 "run_typed: budget exhausted with no text".into(),
485 ));
486 }
487 };
488 serde_json::from_str::<T>(&text).map_err(|e| {
489 HarnessError::Other(format!(
490 "run_typed: decode {} failed: {e} — raw text was: {text}",
491 std::any::type_name::<T>()
492 ))
493 })
494 }
495
496 /// Run with a one-off `ResponseFormat` override (doesn't touch `self`).
497 pub async fn run_with_response_format(
498 &self,
499 task: Task,
500 world: &mut World,
501 max_iters: u32,
502 fmt: ResponseFormat,
503 ) -> Result<Outcome, HarnessError> {
504 // Borrow checker won't let us swap `self.response_format` because
505 // `self` is `&`. Easiest workaround: hand-roll the same setup that
506 // `run_with_seed_history` does, but with our `fmt`. We do this by
507 // calling through a private helper.
508 self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
509 .await
510 }
511
512 async fn run_with_seed_history_and_format(
513 &self,
514 task: Task,
515 seed: Vec<Turn>,
516 world: &mut World,
517 max_iters: u32,
518 fmt_override: Option<ResponseFormat>,
519 ) -> Result<Outcome, HarnessError> {
520 let mut ctx = Context::new(task);
521 ctx.policy.max_iters = max_iters;
522 ctx.tools = self.tools.schemas();
523 ctx.history = seed;
524 ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
525 self.run_built_context(ctx, world).await
526 }
527
528 /// Like `run_with_max_iters` but seeds `ctx.history` with `seed` **before**
529 /// the current user task is appended. Use this for multi-turn REPLs so
530 /// prior conversation lives in `ctx.history` (where the Compactor can see
531 /// it) instead of being concatenated into `task.description` (where it
532 /// previously bypassed compaction entirely — see audit #2).
533 pub async fn run_with_seed_history(
534 &self,
535 task: Task,
536 seed: Vec<Turn>,
537 world: &mut World,
538 max_iters: u32,
539 ) -> Result<Outcome, HarnessError> {
540 self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
541 .await
542 }
543
544 /// Like [`run_with_seed_history`](Self::run_with_seed_history) but also seeds
545 /// `ctx.metadata` with per-request key/values. Hooks and a
546 /// [`ModelRouter`](harness_models::ModelRouter) read this map — e.g.
547 /// `audit.actor` / `audit.session` for the audit trail, or
548 /// `router.keep_local` to pin a request to the local model. This is the
549 /// entry point a serving layer uses to pass caller identity and routing
550 /// flags into a single, shared, reused loop.
551 pub async fn run_with_seed_and_metadata(
552 &self,
553 task: Task,
554 seed: Vec<Turn>,
555 metadata: std::collections::BTreeMap<String, serde_json::Value>,
556 world: &mut World,
557 max_iters: u32,
558 ) -> Result<Outcome, HarnessError> {
559 let mut ctx = Context::new(task);
560 ctx.policy.max_iters = max_iters;
561 ctx.tools = self.tools.schemas();
562 ctx.history = seed;
563 ctx.metadata = metadata;
564 ctx.response_format = self.response_format.clone();
565 self.run_built_context(ctx, world).await
566 }
567
568 /// Start a persistent multi-turn [`Session`]. Each `turn` re-runs the loop
569 /// against the accumulated history with a **stable prefix** (system +
570 /// name-sorted tool schemas), so a provider's prefix cache (e.g. DeepSeek's,
571 /// ~10% price on cache-hit tokens) hits across turns instead of paying full
572 /// price to re-read the same bytes every round. For maximum hit rate, keep
573 /// your guides' output stable (put per-turn volatile context in the message,
574 /// not a recomputed system guide).
575 pub fn session(&self) -> Session<'_, M> {
576 Session {
577 loop_: self,
578 history: Vec::new(),
579 max_iters: harness_core::Policy::default().max_iters,
580 }
581 }
582
583 /// Inner ReAct loop on an already-prepared `Context`. Use the public
584 /// `run*` methods unless you need to inject a non-standard `Context`
585 /// (e.g. `run_with_response_format` does to apply a one-off
586 /// `ResponseFormat` without mutating `self`).
587 async fn run_built_context(
588 &self,
589 mut ctx: Context,
590 world: &mut World,
591 ) -> Result<Outcome, HarnessError> {
592 if ctx.system.is_empty() && !self.system.is_empty() {
593 ctx.system = self.system.clone();
594 }
595
596 // Size the context budget to the model actually in use.
597 //
598 // Compaction fires at a fraction of `max_input_tokens`, so a value
599 // unrelated to the model is wrong in one of two directions: too high and
600 // the provider rejects the request before the compactor ever runs (a 32k
601 // model under the 150k default would need 112,500 tokens to trigger, and
602 // it cannot hold that many); too low and the loop discards history it
603 // could have kept. Nothing read `ModelInfo::context_window` before this —
604 // the framework's own defaults disagreed, 150,000 against 128,000.
605 //
606 // Only when the caller left the default in place; an explicit policy is
607 // a decision and stays untouched. The output allowance is reserved,
608 // because the window is shared between the prompt and the reply.
609 if ctx.policy.max_input_tokens == harness_core::Policy::default().max_input_tokens {
610 let window = self.model.info().context_window;
611 if window > 0 {
612 // Reserve room for the reply, but never let the reservation eat
613 // the window: an 8k model with the default 8k output allowance
614 // would leave 0 tokens for input, and every turn — a 26-token
615 // one included — would run all five compaction stages against a
616 // budget of 1. Cap the reservation at a quarter of the window.
617 let reserve = ctx.policy.max_output_tokens.min(window / 4);
618 ctx.policy.max_input_tokens = window.saturating_sub(reserve).max(1);
619 }
620 }
621
622 self.hooks.fire(
623 &Event::SessionStart {
624 source: SessionSource::Startup,
625 },
626 world,
627 );
628
629 // ── recall: resolve owner/session, ensure the session row ──
630 let (recall_owner, recall_session) = if self.recall.is_some() {
631 use std::sync::atomic::Ordering;
632 let owner = crate::recall_owner(world);
633 let session = world
634 .profile
635 .extra
636 .get("recall_session")
637 .and_then(|v| v.as_str())
638 .map(|s| s.to_string())
639 .unwrap_or_else(|| {
640 format!(
641 "sess-{}-{}",
642 world.clock.now_ms(),
643 RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
644 )
645 });
646 if let Some(store) = &self.recall {
647 let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
648 if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
649 tracing::warn!(error = %e, "recall ensure_session failed");
650 }
651 }
652 (owner, session)
653 } else {
654 (String::new(), String::new())
655 };
656
657 let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
658 if self.recall.is_none() {
659 tracing::warn!(
660 "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
661 );
662 None
663 } else {
664 self.recall
665 .clone()
666 .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
667 }
668 } else {
669 None
670 };
671 let all_guides: Vec<&Arc<dyn Guide>> =
672 self.guides.iter().chain(recall_guide.iter()).collect();
673 for g in &all_guides {
674 if g.scope().matches(&ctx.task) {
675 self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
676 g.apply(&mut ctx, world).await?;
677 self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
678 }
679 }
680
681 ctx.history.push(Turn {
682 role: TurnRole::User,
683 blocks: vec![Block::Text(ctx.task.description.clone())],
684 });
685
686 if self.recall.is_some() {
687 self.recall_append(
688 &recall_owner,
689 &recall_session,
690 harness_core::RecallMessage::new(
691 "user",
692 ctx.task.description.clone(),
693 world.clock.now_ms(),
694 ),
695 )
696 .await;
697 }
698
699 // Running totals — surface to caller even on BudgetExhausted.
700 let mut tools_called: u32 = 0;
701 let mut total_usage = harness_core::Usage::default();
702 let mut last_text: Option<String> = None;
703
704 // Stuck-detector state: the previous round's tool-call fingerprint and
705 // how many consecutive rounds have repeated it.
706 let mut last_fingerprint: Option<String> = None;
707 let mut repeat_count: u32 = 0;
708 // Read-only calls already answered this run, cleared whenever anything
709 // mutates the world. See `ToolResultPolicy::dedupe_repeats`.
710 let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
711 // How many times the model has stopped mid-work with nothing to show.
712 let mut acceptance_retries_left = self.acceptance_retries;
713
714 for iter in 0..ctx.policy.max_iters {
715 self.hooks.fire(&Event::Heartbeat { iter }, world);
716
717 // Compaction with hysteresis: only once over the high-water mark,
718 // then escalate stage-by-stage, re-estimating after each, and stop
719 // the moment we're back under target. Avoids over-compacting to the
720 // lossy AutoCompact stage and avoids rewriting history (→ prefix
721 // cache miss) every turn. Token estimate is calibrated against the
722 // last real `input_tokens` via `CALIBRATION_KEY`.
723 let mut budget = self.compactor.budget(&ctx);
724 if budget.ratio() > self.compaction.high_water {
725 for stage in CompactionStage::ALL {
726 if budget.ratio() <= self.compaction.target {
727 break;
728 }
729 self.hooks.fire(&Event::PreCompact { stage }, world);
730 let before = budget.used;
731 self.compactor.compact(stage, &mut ctx).await?;
732 budget = self.compactor.budget(&ctx);
733 self.hooks.fire(
734 &Event::PostCompact {
735 stage,
736 before,
737 after: budget.used,
738 },
739 world,
740 );
741 }
742 }
743
744 // Per-iteration guides — recall-style adapters that want to
745 // refresh their injected context every turn (e.g. MemoryGuide
746 // re-recalling against the latest user message). Default
747 // `apply_before_iter` is a no-op, so this loop is cheap for
748 // guides that don't override it.
749 for g in &all_guides {
750 if g.scope().matches(&ctx.task)
751 && let Err(e) = g.apply_before_iter(&mut ctx, world).await
752 {
753 tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
754 }
755 }
756
757 self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
758 let out = if self.streaming {
759 self.complete_via_stream(&ctx, world).await?
760 } else {
761 self.model.complete(&ctx).await?
762 };
763 self.hooks.fire(&Event::PostModel { out: &out }, world);
764
765 // Calibrate the compactor against ground truth: `ctx` still holds
766 // exactly what we just sent, so `budget().used` is the estimate for
767 // it. Nudge the stored correction so estimate·correction ≈ the
768 // model's real `input_tokens` next time. Self-correcting (converges
769 // even as the raw estimate drifts); clamped so one odd turn can't
770 // blow it up. This is what makes compaction fire at the right moment
771 // for *this* model + language instead of a blind char heuristic.
772 if out.usage.input_tokens > 0 {
773 let used = self.compactor.budget(&ctx).used;
774 if used > 0 {
775 let prev = ctx
776 .metadata
777 .get(CALIBRATION_KEY)
778 .and_then(|v| v.as_f64())
779 .filter(|f| f.is_finite() && *f > 0.0)
780 .unwrap_or(1.0);
781 let next =
782 (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
783 ctx.metadata
784 .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
785 }
786 }
787
788 // Accumulate usage even if the run later exhausts budget.
789 total_usage.input_tokens += out.usage.input_tokens;
790 total_usage.output_tokens += out.usage.output_tokens;
791 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
792 if let Some(t) = &out.text {
793 last_text = Some(t.clone());
794 }
795 ctx.push_model_output(&out);
796
797 if self.recall.is_some() {
798 let calls = if out.tool_calls.is_empty() {
799 None
800 } else {
801 serde_json::to_string(&out.tool_calls).ok()
802 };
803 let mut m = harness_core::RecallMessage::new(
804 "assistant",
805 out.text.clone().unwrap_or_default(),
806 world.clock.now_ms(),
807 );
808 m.tool_calls = calls;
809 self.recall_append(&recall_owner, &recall_session, m).await;
810 }
811
812 if out.tool_calls.is_empty() {
813 // The model stopping is its opinion that the work is done.
814 // Before taking it as fact, run whatever the caller said
815 // "done" actually means. A failure goes back as an instruction
816 // and the loop carries on; the retry cap keeps a model that
817 // ignores corrections from eating the budget.
818 let mut verdict: Option<Verdict> = None;
819 if !self.acceptance.is_empty() {
820 // Record this turn first — the checks read the transcript.
821 let mut probe = ctx.clone();
822 probe.history.push(Turn {
823 role: TurnRole::Assistant,
824 blocks: out
825 .text
826 .as_deref()
827 .filter(|t| !t.trim().is_empty())
828 .map(|t| vec![Block::Text(t.to_string())])
829 .unwrap_or_default(),
830 });
831 for check in &self.acceptance {
832 let v = check.check(&probe, world).await;
833 if !v.passed {
834 tracing::info!(
835 check = check.name(),
836 reason = %v.reason,
837 "acceptance failed"
838 );
839 verdict = Some(v);
840 break;
841 }
842 }
843 // Everything passed. Say so explicitly: "checked, and it
844 // holds up" is a different claim from "nobody looked", and
845 // the host has to be able to tell them apart.
846 verdict = verdict.or_else(|| Some(Verdict::passed()));
847 }
848
849 if let Some(v) = verdict.clone().filter(|v| !v.passed)
850 && acceptance_retries_left > 0
851 && iter + 1 < ctx.policy.max_iters
852 {
853 acceptance_retries_left -= 1;
854 ctx.history.push(Turn {
855 role: TurnRole::User,
856 blocks: vec![Block::Text(v.reason)],
857 });
858 continue;
859 }
860
861 self.hooks.fire(&Event::TaskCompleted, world);
862 self.hooks.fire(&Event::SessionEnd, world);
863 self.run_learning_review(&ctx, world, tools_called).await;
864 // Thinking models (e.g. Qwen3 via Ollama) sometimes emit the
865 // whole answer into the reasoning channel and leave `text`
866 // empty. Fall back to the reasoning so the turn isn't blank —
867 // but `verified` says whether anyone agreed it was done.
868 let text = out
869 .text
870 .filter(|t| !t.trim().is_empty())
871 .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
872 return Ok(Outcome::Done {
873 text,
874 iters: iter + 1,
875 tools_called,
876 usage: total_usage,
877 verified: verdict,
878 });
879 }
880
881 // ── stuck detection ─────────────────────────────────────────
882 // The model asked for tools again. If it's the *same* request as
883 // last round, it's spinning: nudge it to change tack, then abort
884 // cleanly rather than burn the rest of the budget on the loop.
885 if self.stuck.enabled {
886 let fp = tool_call_fingerprint(&out.tool_calls);
887 if last_fingerprint.as_ref() == Some(&fp) {
888 repeat_count += 1;
889 } else {
890 repeat_count = 1;
891 last_fingerprint = Some(fp);
892 }
893
894 if repeat_count >= self.stuck.abort_after {
895 let reason =
896 format!("repeated the same tool call {repeat_count}× without progress");
897 tracing::warn!(repeated = repeat_count, "stuck: aborting run");
898 self.hooks.fire(&Event::SessionEnd, world);
899 return Ok(Outcome::Stuck {
900 reason,
901 repeated: repeat_count,
902 iters: iter + 1,
903 last_text,
904 tools_called,
905 usage: total_usage,
906 });
907 }
908
909 if repeat_count == self.stuck.nudge_after {
910 tracing::warn!(
911 repeated = repeat_count,
912 "stuck: nudging model to change approach"
913 );
914 ctx.push_feedback(vec![harness_core::Signal {
915 severity: harness_core::Severity::Warn,
916 origin: "stuck-detector".into(),
917 message: format!(
918 "You have issued the same tool call {repeat_count} rounds in a row \
919 without making progress."
920 ),
921 agent_hint: Some(
922 "Stop repeating it. Inspect the actual tool result/error, try a \
923 different approach, or give your final answer with no tool call."
924 .into(),
925 ),
926 auto_fix: None,
927 location: None,
928 }]);
929 }
930 }
931
932 // Parallel-safe prefetch: dispatch the *leading run* of read-only
933 // tool calls concurrently (a mutating tool is a serial barrier).
934 // The sequential loop below still processes every call in order —
935 // hooks, sensors, and history stay ordered — only the dispatch IO
936 // overlaps. Reads before any write are safe; anything at/after the
937 // first mutating call runs on the normal path.
938 let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
939 {
940 let lead: Vec<&_> = out
941 .tool_calls
942 .iter()
943 .take_while(|c| {
944 self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
945 })
946 .collect();
947 if lead.len() > 1 {
948 let futs =
949 lead.iter().map(|c| {
950 let mut w = world.clone();
951 let action = Action {
952 tool: c.name.clone(),
953 call_id: c.id.clone(),
954 args: c.args.clone(),
955 };
956 async move {
957 let r = self.tools.dispatch(&action, &mut w).await.unwrap_or_else(
958 |e| ToolResult {
959 ok: false,
960 content: serde_json::json!({"error": e.to_string()}),
961 trace: None,
962 },
963 );
964 (action.call_id, r)
965 }
966 });
967 for (id, r) in futures::future::join_all(futs).await {
968 prefetched.insert(id, r);
969 }
970 }
971 }
972
973 for call in &out.tool_calls {
974 let action = Action {
975 tool: call.name.clone(),
976 call_id: call.id.clone(),
977 args: call.args.clone(),
978 };
979
980 // PreToolUse hook can deny destructive actions
981 if let HookOutcome::Deny { reason } = self
982 .hooks
983 .fire(&Event::PreToolUse { action: &action }, world)
984 {
985 ctx.history.push(Turn {
986 role: TurnRole::Tool,
987 blocks: vec![Block::ToolResult {
988 call_id: action.call_id.clone(),
989 content: serde_json::json!({
990 "ok": false,
991 "denied_by_hook": reason,
992 }),
993 }],
994 });
995 if self.recall.is_some() {
996 self.recall_append(
997 &recall_owner,
998 &recall_session,
999 harness_core::RecallMessage::new(
1000 "tool",
1001 format!("[denied by hook] {reason}"),
1002 world.clock.now_ms(),
1003 )
1004 .with_tool_name(action.tool.clone()),
1005 )
1006 .await;
1007 }
1008 continue;
1009 }
1010
1011 // Use the concurrently-prefetched result if we have one;
1012 // otherwise dispatch now.
1013 let result = if let Some(r) = prefetched.remove(&action.call_id) {
1014 r
1015 } else {
1016 match self.tools.dispatch(&action, world).await {
1017 Ok(r) => r,
1018 Err(e) => ToolResult {
1019 ok: false,
1020 content: serde_json::json!({"error": e.to_string()}),
1021 trace: None,
1022 },
1023 }
1024 };
1025 tools_called += 1;
1026
1027 // Decide the final payload *before* announcing the result, so
1028 // hooks, telemetry and the context all describe the same thing:
1029 // an audit that logs a 200 KB blob the model never saw is not an
1030 // audit of what happened.
1031 let result = ToolResult {
1032 content: self.shape_result(&action, &result, &mut answered),
1033 ..result
1034 };
1035 self.hooks.fire(
1036 &Event::PostToolUse {
1037 action: &action,
1038 result: &result,
1039 },
1040 world,
1041 );
1042
1043 ctx.history.push(Turn {
1044 role: TurnRole::Tool,
1045 blocks: vec![Block::ToolResult {
1046 call_id: action.call_id.clone(),
1047 content: result.content.clone(),
1048 }],
1049 });
1050
1051 if self.recall.is_some() {
1052 let body = serde_json::to_string(&result.content).unwrap_or_default();
1053 self.recall_append(
1054 &recall_owner,
1055 &recall_session,
1056 harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
1057 .with_tool_name(action.tool.clone()),
1058 )
1059 .await;
1060 }
1061
1062 // run self-correct sensors
1063 let mut all_signals = Vec::new();
1064 for s in &self.sensors {
1065 if s.stage() != Stage::SelfCorrect {
1066 continue;
1067 }
1068 self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
1069 let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
1070 tracing::warn!(?e, "sensor failed");
1071 Vec::new()
1072 });
1073 self.hooks.fire(
1074 &Event::PostSensor {
1075 sensor: s.id(),
1076 signals: &sigs,
1077 },
1078 world,
1079 );
1080 all_signals.extend(sigs);
1081 }
1082 if !all_signals.is_empty() {
1083 let bundle = SignalSet::new(all_signals);
1084 let (patches, remaining) = bundle.partition_auto_fix();
1085
1086 // audit #7: each patch goes through PreAutoFix.
1087 // Hooks can Deny (skip silently). Default safelist on
1088 // RunCommand catches the obvious misuses with no hook.
1089 let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
1090 if !is_default_safe_fix(p) {
1091 tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
1092 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1093 return false;
1094 }
1095 match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
1096 HookOutcome::Deny { reason } => {
1097 tracing::warn!(?p, %reason, "auto-fix denied by hook");
1098 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
1099 false
1100 }
1101 _ => true,
1102 }
1103 }).collect();
1104
1105 let applied = apply_patches(&approved, world).await;
1106 // Emit PostAutoFix for each approved patch with the application result.
1107 for (i, p) in approved.iter().enumerate() {
1108 self.hooks.fire(
1109 &Event::PostAutoFix {
1110 patch: p,
1111 applied: i < applied.len(),
1112 },
1113 world,
1114 );
1115 }
1116 if !applied.is_empty() {
1117 ctx.push_feedback(vec![harness_core::Signal {
1118 severity: harness_core::Severity::Hint,
1119 origin: "auto-fix".into(),
1120 message: format!(
1121 "applied {} auto-fix patch(es): {applied:?}",
1122 applied.len()
1123 ),
1124 agent_hint: Some(
1125 "re-check the affected files before continuing".into(),
1126 ),
1127 auto_fix: None,
1128 location: None,
1129 }]);
1130 }
1131 if remaining.has_blocking() {
1132 ctx.push_feedback(remaining.signals);
1133 }
1134 }
1135 }
1136 }
1137 // ── Budget exhausted ─────────────────────────────────────────
1138 // Force a final synthesis pass with tools DISABLED. Otherwise the
1139 // model often spins on tool calls right up to the budget cap and
1140 // never emits a text conclusion, leaving the caller with nothing
1141 // but `last_text` from some earlier intermediate turn (or None).
1142 //
1143 // The synthesis call is "free" — it costs one extra model call
1144 // beyond max_iters but doesn't count toward `iters`. The result
1145 // lands in `last_text` so callers display it as the answer.
1146 let synthesised = self
1147 .force_final_synthesis(&mut ctx, world, &mut total_usage)
1148 .await;
1149 if let Some(t) = synthesised {
1150 last_text = Some(t);
1151 }
1152
1153 self.hooks.fire(&Event::SessionEnd, world);
1154 self.run_learning_review(&ctx, world, tools_called).await;
1155 Ok(Outcome::BudgetExhausted {
1156 iters: ctx.policy.max_iters,
1157 last_text,
1158 tools_called,
1159 usage: total_usage,
1160 })
1161 }
1162
1163 /// Drive `Model::stream()` and assemble the result into a `ModelOutput`,
1164 /// firing `Event::ModelTokenDelta` for each text fragment along the way.
1165 ///
1166 /// Adapters that don't implement real streaming (e.g. `GeminiNative` /
1167 /// `AnthropicNative` today) fall back to the default trait impl, which
1168 /// runs `complete()` and emits the whole reply as a single delta. That
1169 /// works — the loop sees one big `ModelDelta::Text(...)` followed by
1170 /// `Stop`, fires one big `ModelTokenDelta`, and proceeds. So enabling
1171 /// `streaming` is safe regardless of which provider the user picked.
1172 async fn complete_via_stream(
1173 &self,
1174 ctx: &Context,
1175 world: &mut World,
1176 ) -> Result<ModelOutput, HarnessError> {
1177 use futures::StreamExt;
1178 let mut stream = self
1179 .model
1180 .stream(ctx)
1181 .await
1182 .map_err(harness_core::HarnessError::Model)?;
1183 let mut text = String::new();
1184 let mut reasoning = String::new();
1185 let mut usage = Usage::default();
1186 let mut stop_reason = StopReason::EndTurn;
1187 // Insertion-ordered map: index → (id, name, args). We can't use the
1188 // tool-call id as the primary key because the stream may emit args
1189 // chunks before the first chunk that carries the id; the OpenAI-compat
1190 // SSE parser already does its own buffering and surfaces `id` in
1191 // ToolCallStart, but be lenient with adapters that may interleave.
1192 let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
1193 let mut tool_order: Vec<String> = Vec::new();
1194 while let Some(item) = stream.next().await {
1195 let delta = item.map_err(harness_core::HarnessError::Model)?;
1196 match delta {
1197 ModelDelta::Text(t) => {
1198 if !t.is_empty() {
1199 self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
1200 text.push_str(&t);
1201 }
1202 }
1203 ModelDelta::ToolCallStart { id, name } => {
1204 if !tool_starts.contains_key(&id) {
1205 tool_order.push(id.clone());
1206 }
1207 tool_starts
1208 .entry(id)
1209 .or_insert_with(|| (name, String::new()));
1210 }
1211 ModelDelta::ToolCallArgs { id, partial_json } => {
1212 let entry = tool_starts
1213 .entry(id.clone())
1214 .or_insert_with(|| (String::new(), String::new()));
1215 if !tool_order.iter().any(|k| k == &id) {
1216 tool_order.push(id);
1217 }
1218 entry.1.push_str(&partial_json);
1219 }
1220 ModelDelta::ToolCallEnd { .. } => {}
1221 ModelDelta::Usage(u) => usage = u,
1222 ModelDelta::Stop(r) => stop_reason = r,
1223 ModelDelta::Reasoning(s) => {
1224 // Streamed reasoning arrives as token fragments, not lines —
1225 // concatenate verbatim (same as `text`), don't insert newlines.
1226 reasoning.push_str(&s);
1227 }
1228 // ModelDelta is `#[non_exhaustive]`; ignore future variants
1229 // we don't yet understand.
1230 _ => {}
1231 }
1232 }
1233 let tool_calls: Vec<ToolCall> = tool_order
1234 .into_iter()
1235 .filter_map(|id| {
1236 tool_starts.remove(&id).map(|(name, args)| {
1237 let args_v = serde_json::from_str::<serde_json::Value>(&args)
1238 .unwrap_or(serde_json::Value::String(args));
1239 ToolCall {
1240 id,
1241 name,
1242 args: args_v,
1243 }
1244 })
1245 })
1246 .collect();
1247 // Reconcile stop_reason with what actually came out — adapters
1248 // sometimes emit `Stop(EndTurn)` even after tool_calls, which would
1249 // confuse downstream consumers that branch on stop_reason alone.
1250 let stop_reason = if !tool_calls.is_empty() {
1251 StopReason::ToolUse
1252 } else {
1253 stop_reason
1254 };
1255 Ok(ModelOutput {
1256 text: if text.is_empty() { None } else { Some(text) },
1257 tool_calls,
1258 usage,
1259 stop_reason,
1260 reasoning: if reasoning.is_empty() {
1261 None
1262 } else {
1263 Some(reasoning)
1264 },
1265 // `ModelDelta` has no image variant: the verified image-output
1266 // path (Gemini image models over chat) answers non-streamed, so
1267 // there is nothing to accumulate here. A streaming provider that
1268 // emits images would need a `ModelDelta::Image` first.
1269 images: Vec::new(),
1270 })
1271 }
1272
1273 /// Best-effort append to the recall store. Never fails the turn.
1274 /// What a tool result contributes to the context: repeat suppression first,
1275 /// then the size ceiling. A repeat that is also oversized collapses to the
1276 /// pointer rather than to a truncated copy of what the model already holds.
1277 fn shape_result(
1278 &self,
1279 action: &Action,
1280 result: &ToolResult,
1281 answered: &mut std::collections::HashSet<String>,
1282 ) -> serde_json::Value {
1283 if !(self.tool_results.dedupe_repeats && result.ok) {
1284 return self.cap_result(&action.tool, &result.content);
1285 }
1286 match self.tools.risk(&action.tool) {
1287 Some(harness_core::ToolRisk::ReadOnly) => {
1288 let fp = format!("{}({})", action.tool, action.args);
1289 if answered.contains(&fp) {
1290 tracing::info!(
1291 target: "harness.telemetry",
1292 event = "tool.result.repeat",
1293 "gen_ai.tool.name" = %action.tool,
1294 );
1295 serde_json::json!({
1296 "repeat_of_earlier_call": true,
1297 "tool": action.tool,
1298 "note": "You already made this exact call in this run and nothing has \
1299 changed the workspace since. The earlier result above still \
1300 stands — use it rather than asking again.",
1301 })
1302 } else {
1303 answered.insert(fp);
1304 self.cap_result(&action.tool, &result.content)
1305 }
1306 }
1307 // A write invalidates every earlier read.
1308 _ => {
1309 answered.clear();
1310 self.cap_result(&action.tool, &result.content)
1311 }
1312 }
1313 }
1314
1315 /// Enforce [`ToolResultPolicy`] on one result before it reaches the context.
1316 ///
1317 /// Truncation is at the byte level on the serialized form, then handed back
1318 /// as a marker object rather than mangled JSON — a half-parsed blob is worse
1319 /// for the model than an honest "this was cut". The marker says how much was
1320 /// dropped and what to do instead, so the next turn narrows the request
1321 /// rather than repeating it.
1322 fn cap_result(&self, tool: &str, content: &serde_json::Value) -> serde_json::Value {
1323 let Some(max) = self.tool_results.max_bytes else {
1324 return content.clone();
1325 };
1326 let serialized = content.to_string();
1327 if serialized.len() <= max {
1328 return content.clone();
1329 }
1330 // Cut on a char boundary so the kept head is valid UTF-8.
1331 let mut end = max;
1332 while end > 0 && !serialized.is_char_boundary(end) {
1333 end -= 1;
1334 }
1335 tracing::warn!(
1336 target: "harness.telemetry",
1337 event = "tool.result.truncated",
1338 "gen_ai.tool.name" = %tool,
1339 bytes = serialized.len(),
1340 max_bytes = max,
1341 );
1342 serde_json::json!({
1343 "truncated": true,
1344 "tool": tool,
1345 "bytes_total": serialized.len(),
1346 "bytes_kept": end,
1347 "head": serialized[..end],
1348 "note": format!(
1349 "This result was {} bytes and was cut to {} to protect the context window. \
1350 Do not ask for it again unchanged — narrow it: request a smaller range, \
1351 a filter, or a specific field.",
1352 serialized.len(), end
1353 ),
1354 })
1355 }
1356
1357 async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
1358 if let Some(store) = &self.recall
1359 && let Err(e) = store.append(owner, session, &msg).await
1360 {
1361 tracing::warn!(error = %e, "recall append failed");
1362 }
1363 }
1364
1365 /// Best-effort post-session review. Never affects the finished run.
1366 async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
1367 let Some(cfg) = &self.learning else { return };
1368 if tools_called < cfg.nudge_interval {
1369 return;
1370 }
1371 let transcript = crate::render_transcript(&ctx.history, 12_000);
1372 let task = harness_core::Task {
1373 description: format!(
1374 "{}\n\n## Conversation transcript\n{}",
1375 cfg.review_prompt, transcript
1376 ),
1377 source: None,
1378 deadline: None,
1379 };
1380 let mut spec =
1381 crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
1382 for t in &cfg.tools {
1383 spec = spec.with_tool(t.clone());
1384 }
1385 let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
1386 // Box::pin breaks the recursive async-future cycle: AgentLoop<M> →
1387 // run_learning_review → Subagent<DynModel>::run →
1388 // AgentLoop<Arc<dyn Model>>::run_built_context. Without pinning the
1389 // compiler rejects the infinite-sized future.
1390 if let Err(e) = Box::pin(sub.run(world)).await {
1391 tracing::warn!(error = %e, "learning review failed");
1392 }
1393 }
1394
1395 /// One final model call with tools removed, asking it to write the
1396 /// best-effort conclusion from whatever it has already gathered.
1397 ///
1398 /// Errors from the model are swallowed — observability is best-effort
1399 /// here, and a transport blip during synthesis should not turn a
1400 /// near-complete run into a hard failure.
1401 async fn force_final_synthesis(
1402 &self,
1403 ctx: &mut Context,
1404 world: &mut World,
1405 total_usage: &mut harness_core::Usage,
1406 ) -> Option<String> {
1407 const SYNTHESIS_PROMPT: &str = "[system: iteration budget exhausted] \
1408 You have run out of tool-calling iterations. Write your final answer \
1409 NOW using only the tool results already in this conversation. Do not \
1410 request more tools. Mark facts you could not verify as UNKNOWN. \
1411 Include source URLs for every claim that is not UNKNOWN.";
1412
1413 // Signal to any observer (LiveProgressHook, SessionRecorder, custom
1414 // hooks) that we've used 100% of the budget and are about to force
1415 // synthesis. Pre-existing `BudgetWarning` event was unused; this is
1416 // its natural home.
1417 self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
1418
1419 // Snapshot + clear tool schemas so the model has no choice but text.
1420 let saved_tools = std::mem::take(&mut ctx.tools);
1421 ctx.history.push(Turn {
1422 role: TurnRole::User,
1423 blocks: vec![Block::Text(SYNTHESIS_PROMPT.into())],
1424 });
1425
1426 self.hooks.fire(&Event::PreModel { ctx }, world);
1427 let result = self.model.complete(ctx).await;
1428 ctx.tools = saved_tools;
1429
1430 match result {
1431 Ok(out) => {
1432 self.hooks.fire(&Event::PostModel { out: &out }, world);
1433 total_usage.input_tokens += out.usage.input_tokens;
1434 total_usage.output_tokens += out.usage.output_tokens;
1435 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1436 ctx.push_model_output(&out);
1437 out.text
1438 }
1439 Err(_) => None,
1440 }
1441 }
1442}
1443
1444/// A persistent multi-turn conversation over one [`AgentLoop`].
1445///
1446/// Holds the append-only history and, on each [`turn`](Session::turn), re-runs
1447/// the loop against a **stable prefix** (system + name-sorted tool schemas).
1448/// That byte-stable prefix is what lets a provider's prefix cache hit across
1449/// turns — the difference between paying full price to re-read the same context
1450/// every round and paying ~10% for the cached bytes (DeepSeek).
1451pub struct Session<'a, M: Model> {
1452 loop_: &'a AgentLoop<M>,
1453 history: Vec<Turn>,
1454 max_iters: u32,
1455}
1456
1457impl<'a, M: Model> Session<'a, M> {
1458 pub fn with_max_iters(mut self, n: u32) -> Self {
1459 self.max_iters = n;
1460 self
1461 }
1462 /// Preload prior turns (e.g. resumed from disk).
1463 pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
1464 self.history = seed;
1465 self
1466 }
1467 /// The accumulated conversation so far.
1468 pub fn history(&self) -> &[Turn] {
1469 &self.history
1470 }
1471 /// Start over (branch): drop the accumulated turns.
1472 pub fn reset(&mut self) {
1473 self.history.clear();
1474 }
1475
1476 /// Send one user message. Runs the ReAct loop against the accumulated
1477 /// history, then appends this user turn + the assistant reply so the next
1478 /// turn extends the same cached prefix.
1479 pub async fn turn(
1480 &mut self,
1481 message: impl Into<String>,
1482 world: &mut World,
1483 ) -> Result<Outcome, HarnessError> {
1484 let message = message.into();
1485 let task = Task {
1486 description: message.clone(),
1487 source: None,
1488 deadline: None,
1489 };
1490 let outcome = self
1491 .loop_
1492 .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
1493 .await?;
1494 let reply = match &outcome {
1495 Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
1496 Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
1497 last_text.clone().unwrap_or_default()
1498 }
1499 };
1500 self.history.push(Turn {
1501 role: TurnRole::User,
1502 blocks: vec![Block::Text(message)],
1503 });
1504 self.history.push(Turn {
1505 role: TurnRole::Assistant,
1506 blocks: vec![Block::Text(reply)],
1507 });
1508 Ok(outcome)
1509 }
1510}
1511
1512/// Audit #7: default safelist for `FixPatch::RunCommand`.
1513///
1514/// Sensors emitting `RunCommand` patches would otherwise be a silent
1515/// arbitrary-code-execution channel. We restrict the *program* by name to a
1516/// short list of well-known, side-effect-bounded formatters/fixers. Anything
1517/// else returns false and the patch is rejected (write your own `PreAutoFix`
1518/// hook returning `HookOutcome::Allow` to widen the policy).
1519///
1520/// `ReplaceFile` and `UnifiedDiff` are not restricted here — they only touch
1521/// files inside the workspace and are covered by the symlink-safe path
1522/// resolution in `harness-tools-fs`.
1523pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
1524 use harness_core::FixPatch;
1525 match patch {
1526 FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
1527 FixPatch::RunCommand { program, args, .. } => match program.as_str() {
1528 // Cargo subcommands proven side-effect-bounded.
1529 "cargo" => matches!(
1530 args.first().map(String::as_str),
1531 Some("fmt" | "clippy" | "fix"),
1532 ),
1533 "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
1534 _ => false,
1535 },
1536 // Future FixPatch variants: deny by default — review and add to the list above.
1537 _ => false,
1538 }
1539}
1540
1541/// Monotonic counter for `.harness-patch-*.diff` temp filenames — millisecond
1542/// resolution alone collides under parallel agent runs.
1543static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1544
1545/// Monotonic counter for fallback recall session ids (no `uuid` dep).
1546static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1547
1548/// Apply auto-fix patches; return short descriptions of those that succeeded.
1549///
1550/// Made `pub` (was `pub(crate)`) so integration tests can call it directly.
1551pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
1552 use harness_core::FixPatch;
1553 let mut applied = Vec::new();
1554 for p in patches {
1555 match p {
1556 FixPatch::ReplaceFile { path, content } => {
1557 let abs = world.repo.root.join(path);
1558 if let Some(parent) = abs.parent() {
1559 let _ = tokio::fs::create_dir_all(parent).await;
1560 }
1561 if tokio::fs::write(&abs, content).await.is_ok() {
1562 applied.push(format!("replaced {}", path.display()));
1563 }
1564 }
1565 FixPatch::UnifiedDiff { diff } => {
1566 if try_apply_diff(world, diff).await {
1567 applied.push("unified diff applied".into());
1568 }
1569 }
1570 FixPatch::RunCommand { program, args, cwd } => {
1571 let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
1572 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1573 if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
1574 && out.status == 0
1575 {
1576 applied.push(format!("ran `{program} {}`", args.join(" ")));
1577 }
1578 }
1579 // FixPatch is `#[non_exhaustive]`; unknown variants are skipped.
1580 _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
1581 }
1582 }
1583 applied
1584}
1585
1586/// Write `diff` to a unique temp file and try `patch -p1` first, then `-p0`.
1587/// Returns whether either succeeded. The `-p1`-then-`-p0` order matches the
1588/// reality that most agent-emitted diffs are git-style (need `-p1`) but some
1589/// hand-rolled diffs use repo-relative paths (need `-p0`).
1590async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
1591 use std::sync::atomic::Ordering;
1592 use tokio::io::AsyncWriteExt;
1593
1594 let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
1595 let pid = std::process::id();
1596 let now = world.clock.now_ms();
1597 let tmp = world
1598 .repo
1599 .root
1600 .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
1601
1602 let mut f = match tokio::fs::File::create(&tmp).await {
1603 Ok(f) => f,
1604 Err(e) => {
1605 tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
1606 return false;
1607 }
1608 };
1609 if let Err(e) = f.write_all(diff.as_bytes()).await {
1610 tracing::warn!(error=%e, "could not write patch tempfile");
1611 let _ = tokio::fs::remove_file(&tmp).await;
1612 return false;
1613 }
1614 drop(f);
1615
1616 let tmp_str = tmp.to_string_lossy().to_string();
1617 let mut applied = false;
1618 for strip in ["-p1", "-p0"] {
1619 match world
1620 .runner
1621 .exec(
1622 "patch",
1623 &[strip, "--silent", "-i", tmp_str.as_str()],
1624 Some(world.repo.root.as_path()),
1625 )
1626 .await
1627 {
1628 Ok(out) if out.status == 0 => {
1629 tracing::info!(strip, "patch applied");
1630 applied = true;
1631 break;
1632 }
1633 Ok(out) => {
1634 tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
1635 }
1636 Err(e) => {
1637 tracing::warn!(error=%e, "patch command not available");
1638 break; // patch tool missing — no point trying other strip
1639 }
1640 }
1641 }
1642 let _ = tokio::fs::remove_file(&tmp).await;
1643 applied
1644}