pub mod acceptance;
pub mod goal;
pub mod learning;
pub mod memory_layer;
#[cfg(feature = "otel")]
pub mod otel;
pub mod profile_guide;
pub mod recall_layer;
pub mod receipt;
pub mod registry;
pub mod seal;
pub use acceptance::{Acceptance, FilesExist, NonEmptyAnswer, Verdict};
pub use goal::{Goal, GoalStore, Phase, PhaseStatus};
pub use receipt::{Receipt, ReceiptBuilder};
pub use seal::{SealBreach, SealSet};
pub mod replay;
pub mod subagent;
pub mod telemetry;
pub use learning::*;
pub use memory_layer::*;
pub mod boundary_guide;
pub use boundary_guide::*;
pub use profile_guide::*;
pub use recall_layer::*;
pub use registry::*;
pub use replay::*;
pub use subagent::*;
pub use telemetry::*;
use harness_compactor::{CALIBRATION_KEY, DefaultCompactor};
use harness_core::{
Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
};
use harness_hooks::HookBus;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct StuckPolicy {
pub enabled: bool,
pub nudge_after: u32,
pub abort_after: u32,
}
impl Default for StuckPolicy {
fn default() -> Self {
Self {
enabled: true,
nudge_after: 3,
abort_after: 6,
}
}
}
#[derive(Debug, Clone)]
pub struct ToolResultPolicy {
pub max_bytes: Option<usize>,
pub dedupe_repeats: bool,
pub spill: bool,
}
impl Default for ToolResultPolicy {
fn default() -> Self {
Self {
max_bytes: Some(24 * 1024),
dedupe_repeats: false,
spill: true,
}
}
}
#[derive(Debug, Clone)]
pub struct CompactPolicy {
pub high_water: f32,
pub target: f32,
}
impl Default for CompactPolicy {
fn default() -> Self {
Self {
high_water: 0.75,
target: 0.55,
}
}
}
const SPILL_PREVIEW_BYTES: usize = 4 * 1024;
fn head_of(s: &str, n: usize) -> &str {
let mut end = n.min(s.len());
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn dominant_string(v: &serde_json::Value, total: usize) -> Option<(String, &str)> {
fn walk<'a>(v: &'a serde_json::Value, path: &str, best: &mut Option<(String, &'a str)>) {
match v {
serde_json::Value::String(s) => {
if best.as_ref().is_none_or(|(_, b)| s.len() > b.len()) {
*best = Some((path.to_string(), s.as_str()));
}
}
serde_json::Value::Object(m) => {
for (k, x) in m {
let p = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
walk(x, &p, best);
}
}
serde_json::Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
walk(x, &format!("{path}[{i}]"), best);
}
}
_ => {}
}
}
let mut best = None;
walk(v, "", &mut best);
best.filter(|(_, s)| s.len() * 5 >= total * 4)
}
fn replace_at(v: &mut serde_json::Value, path: &str, with: serde_json::Value) {
let mut cur = v;
let mut rest = path;
loop {
let (seg, tail) = match rest.find('.') {
Some(dot) => (&rest[..dot], &rest[dot + 1..]),
None => (rest, ""),
};
let (key, idx) = match seg.find('[') {
Some(b) => (&seg[..b], seg[b + 1..seg.len() - 1].parse::<usize>().ok()),
None => (seg, None),
};
if !key.is_empty() {
match cur.get_mut(key) {
Some(next) => cur = next,
None => return,
}
}
if let Some(i) = idx {
match cur.get_mut(i) {
Some(next) => cur = next,
None => return,
}
}
if tail.is_empty() {
*cur = with;
return;
}
rest = tail;
}
}
fn spill_oversized(
action: &Action,
content: &serde_json::Value,
serialized: &str,
root: &std::path::Path,
) -> Option<serde_json::Value> {
let dir = root.join(".harness").join("spill");
std::fs::create_dir_all(&dir).ok()?;
let id: String = action
.call_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
.take(48)
.collect();
let id = if id.is_empty() { "call".into() } else { id };
let (rel, preview, meta) = match dominant_string(content, serialized.len()) {
Some((field, text)) => {
let rel = format!(".harness/spill/{id}-{}.txt", action.tool);
std::fs::write(root.join(&rel), text).ok()?;
let mut meta = content.clone();
replace_at(
&mut meta,
&field,
serde_json::Value::String(format!("[{} bytes spilled to {rel}]", text.len())),
);
(
rel,
head_of(text, SPILL_PREVIEW_BYTES).to_string(),
Some(meta),
)
}
None => {
let rel = format!(".harness/spill/{id}-{}.json", action.tool);
let pretty =
serde_json::to_string_pretty(content).unwrap_or_else(|_| serialized.to_string());
std::fs::write(root.join(&rel), &pretty).ok()?;
(rel, head_of(&pretty, SPILL_PREVIEW_BYTES).to_string(), None)
}
};
tracing::warn!(
target: "harness.telemetry",
event = "tool.result.spilled",
"gen_ai.tool.name" = %action.tool,
bytes = serialized.len(),
path = %rel,
);
let mut marker = serde_json::json!({
"spilled": true,
"tool": action.tool,
"bytes_total": serialized.len(),
"path": rel,
"preview": preview,
"note": format!(
"This result was {} bytes — too large to inline, so the FULL content was \
saved to '{rel}' (workspace-relative). Nothing was lost. Retrieve exactly \
the part you need: grep(path=\"{rel}\", pattern=...) or \
read_file(path=\"{rel}\", offset=..., limit=...). Do not repeat the \
original call — it will spill again.",
serialized.len()
),
});
if let Some(meta) = meta
&& meta.to_string().len() <= SPILL_PREVIEW_BYTES
{
marker["meta"] = meta;
}
Some(marker)
}
fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
calls
.iter()
.map(|c| format!("{}({})", c.name, c.args))
.collect::<Vec<_>>()
.join("|")
}
#[derive(Debug, Clone)]
pub enum Outcome {
#[non_exhaustive]
Done {
text: Option<String>,
iters: u32,
tools_called: u32,
usage: harness_core::Usage,
verified: Option<Verdict>,
contract: crate::seal::SealSet,
seal_breach: Option<String>,
},
#[non_exhaustive]
BudgetExhausted {
iters: u32,
last_text: Option<String>,
tools_called: u32,
usage: harness_core::Usage,
},
#[non_exhaustive]
Stuck {
reason: String,
repeated: u32,
iters: u32,
last_text: Option<String>,
tools_called: u32,
usage: harness_core::Usage,
},
}
pub struct AgentLoop<M: Model> {
pub model: M,
pub tools: ToolRegistry,
pub guides: Vec<Arc<dyn Guide>>,
pub sensors: Vec<Arc<dyn Sensor>>,
pub hooks: HookBus,
pub compactor: Arc<dyn Compactor>,
pub tool_timeout: Option<Duration>,
pub response_format: ResponseFormat,
pub streaming: bool,
pub recall: Option<Arc<dyn harness_core::RecallStore>>,
pub recall_auto_inject: bool,
pub learning: Option<LearningConfig>,
pub stuck: StuckPolicy,
pub compaction: CompactPolicy,
pub tool_results: ToolResultPolicy,
pub acceptance: Vec<Arc<dyn Acceptance>>,
pub acceptance_retries: u32,
pub system: Vec<Block>,
}
impl AgentLoop<harness_core::DynModel> {
pub fn boxed(model: Arc<dyn Model>) -> Self {
Self::new(harness_core::DynModel(model))
}
}
impl<M: Model> AgentLoop<M> {
pub fn new(model: M) -> Self {
Self {
model,
tools: ToolRegistry::new(),
guides: Vec::new(),
sensors: Vec::new(),
hooks: HookBus::new(),
compactor: Arc::new(DefaultCompactor::new()),
tool_timeout: Some(Duration::from_secs(120)),
response_format: ResponseFormat::Free,
streaming: false,
recall: None,
recall_auto_inject: false,
learning: None,
stuck: StuckPolicy::default(),
compaction: CompactPolicy::default(),
tool_results: ToolResultPolicy::default(),
acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
acceptance_retries: 1,
system: Vec::new(),
}
}
pub fn with_system(mut self, text: impl Into<String>) -> Self {
self.system = vec![Block::Text(text.into())];
self
}
pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
self.stuck = policy;
self
}
pub fn with_tool_result_policy(mut self, policy: ToolResultPolicy) -> Self {
self.tool_results = policy;
self
}
pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
self.compaction = policy;
self
}
pub fn with_streaming(mut self, enable: bool) -> Self {
self.streaming = enable;
self
}
pub fn with_acceptance(mut self, a: Arc<dyn Acceptance>) -> Self {
self.acceptance.push(a);
self
}
pub fn with_acceptance_set(mut self, set: Vec<Arc<dyn Acceptance>>) -> Self {
self.acceptance = set;
self
}
pub fn with_acceptance_retries(mut self, n: u32) -> Self {
self.acceptance_retries = n;
self
}
pub fn with_tool_timeout(mut self, t: Option<Duration>) -> Self {
self.tool_timeout = t;
self
}
pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
self.compactor = c;
self
}
pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
self.tools.insert(t);
self
}
pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
self.guides.push(g);
self
}
pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
self.sensors.push(s);
self
}
pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
self.hooks.register(h);
self
}
pub fn with_macro_hooks(mut self) -> Self {
self.hooks = self.hooks.with_macro_hooks_take();
self
}
pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
self.tools
.insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
self.recall = Some(store);
self
}
pub fn with_recall_ingest(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
self.recall = Some(store);
self
}
pub fn auto_inject(mut self) -> Self {
self.recall_auto_inject = true;
self
}
pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
self.learning = Some(cfg);
self
}
pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
self.response_format = fmt;
self
}
pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
self.with_response_format(ResponseFormat::JsonSchema {
name: name.into(),
schema,
})
}
pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
let max = harness_core::Policy::default().max_iters;
self.run_with_max_iters(task, world, max).await
}
pub async fn run_receipted(
&self,
task: Task,
world: &mut World,
now_ms: i64,
) -> Result<(Outcome, Receipt), HarnessError> {
let description = task.description.clone();
let handle = self.model.info().handle;
let outcome = self.run(task, world).await?;
let receipt = ReceiptBuilder::new(description, handle, now_ms).build(&outcome);
Ok((outcome, receipt))
}
pub async fn run_goal(
&self,
goal: &mut Goal,
store: &GoalStore,
world: &mut World,
now_ms: i64,
) -> Result<Option<(Outcome, Receipt)>, HarnessError> {
let Some(i) = goal.start_current(now_ms) else {
return Ok(None);
};
let _ = store.save(goal);
let task = Task {
description: goal.brief(),
source: None,
deadline: None,
};
let result = self.run_receipted(task, world, now_ms).await;
match &result {
Ok((_, receipt)) => {
if receipt.passed {
goal.finish(i, receipt.summary(), now_ms);
} else {
goal.fail(i, receipt.summary(), now_ms);
}
}
Err(e) => goal.fail(i, format!("the run errored: {e}"), now_ms),
}
let _ = store.save(goal);
result.map(Some)
}
pub async fn run_with_max_iters(
&self,
task: Task,
world: &mut World,
max_iters: u32,
) -> Result<Outcome, HarnessError> {
self.run_with_seed_history(task, Vec::new(), world, max_iters)
.await
}
pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
where
T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
{
let max = harness_core::Policy::default().max_iters;
self.run_typed_with_max_iters::<T>(task, world, max).await
}
pub async fn run_typed_with_max_iters<T>(
&self,
task: Task,
world: &mut World,
max_iters: u32,
) -> Result<T, HarnessError>
where
T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
{
let schema_root = schemars::schema_for!(T);
let schema = serde_json::to_value(&schema_root)
.map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
let name = std::any::type_name::<T>()
.rsplit("::")
.next()
.unwrap_or("response")
.to_string();
let fmt = ResponseFormat::JsonSchema { name, schema };
let outcome = self
.run_with_response_format(task, world, max_iters, fmt)
.await?;
let text = match outcome {
Outcome::Done { text: Some(t), .. }
| Outcome::BudgetExhausted {
last_text: Some(t), ..
}
| Outcome::Stuck {
last_text: Some(t), ..
} => t,
Outcome::Done { text: None, .. } => {
return Err(HarnessError::Other(
"run_typed: model returned no text".into(),
));
}
Outcome::Stuck {
last_text: None, ..
} => {
return Err(HarnessError::Other(
"run_typed: agent stuck with no text".into(),
));
}
Outcome::BudgetExhausted {
last_text: None, ..
} => {
return Err(HarnessError::Other(
"run_typed: budget exhausted with no text".into(),
));
}
};
serde_json::from_str::<T>(&text).map_err(|e| {
HarnessError::Other(format!(
"run_typed: decode {} failed: {e} — raw text was: {text}",
std::any::type_name::<T>()
))
})
}
pub async fn run_with_response_format(
&self,
task: Task,
world: &mut World,
max_iters: u32,
fmt: ResponseFormat,
) -> Result<Outcome, HarnessError> {
self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
.await
}
async fn run_with_seed_history_and_format(
&self,
task: Task,
seed: Vec<Turn>,
world: &mut World,
max_iters: u32,
fmt_override: Option<ResponseFormat>,
) -> Result<Outcome, HarnessError> {
let mut ctx = Context::new(task);
ctx.policy.max_iters = max_iters;
ctx.tools = self.tools.schemas();
ctx.history = seed;
ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
self.run_built_context(ctx, world).await
}
pub async fn run_with_seed_history(
&self,
task: Task,
seed: Vec<Turn>,
world: &mut World,
max_iters: u32,
) -> Result<Outcome, HarnessError> {
self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
.await
}
pub async fn run_with_seed_and_metadata(
&self,
task: Task,
seed: Vec<Turn>,
metadata: std::collections::BTreeMap<String, serde_json::Value>,
world: &mut World,
max_iters: u32,
) -> Result<Outcome, HarnessError> {
let mut ctx = Context::new(task);
ctx.policy.max_iters = max_iters;
ctx.tools = self.tools.schemas();
ctx.history = seed;
ctx.metadata = metadata;
ctx.response_format = self.response_format.clone();
self.run_built_context(ctx, world).await
}
pub fn session(&self) -> Session<'_, M> {
Session {
loop_: self,
history: Vec::new(),
max_iters: harness_core::Policy::default().max_iters,
}
}
async fn run_built_context(
&self,
mut ctx: Context,
world: &mut World,
) -> Result<Outcome, HarnessError> {
if ctx.system.is_empty() && !self.system.is_empty() {
ctx.system = self.system.clone();
}
if ctx.policy.max_input_tokens == harness_core::Policy::default().max_input_tokens {
let window = self.model.info().context_window;
if window > 0 {
let reserve = ctx.policy.max_output_tokens.min(window / 4);
ctx.policy.max_input_tokens = window.saturating_sub(reserve).max(1);
}
}
self.hooks.fire(
&Event::SessionStart {
source: SessionSource::Startup,
},
world,
);
let (recall_owner, recall_session) = if self.recall.is_some() {
use std::sync::atomic::Ordering;
let owner = crate::recall_owner(world);
let session = world
.profile
.extra
.get("recall_session")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
format!(
"sess-{}-{}",
world.clock.now_ms(),
RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
)
});
if let Some(store) = &self.recall {
let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
tracing::warn!(error = %e, "recall ensure_session failed");
}
}
(owner, session)
} else {
(String::new(), String::new())
};
let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
if self.recall.is_none() {
tracing::warn!(
"auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
);
None
} else {
self.recall
.clone()
.map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
}
} else {
None
};
let all_guides: Vec<&Arc<dyn Guide>> =
self.guides.iter().chain(recall_guide.iter()).collect();
for g in &all_guides {
if g.scope().matches(&ctx.task) {
self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
g.apply(&mut ctx, world).await?;
self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
}
}
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(ctx.task.description.clone())],
});
if self.recall.is_some() {
self.recall_append(
&recall_owner,
&recall_session,
harness_core::RecallMessage::new(
"user",
ctx.task.description.clone(),
world.clock.now_ms(),
),
)
.await;
}
let mut tools_called: u32 = 0;
let mut total_usage = harness_core::Usage::default();
let mut last_text: Option<String> = None;
let mut last_fingerprint: Option<String> = None;
let mut repeat_count: u32 = 0;
let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut acceptance_retries_left = self.acceptance_retries;
let mut seal_breached: Option<String> = None;
let sealed_before: crate::seal::SealSet = {
let paths: Vec<std::path::PathBuf> =
self.acceptance.iter().flat_map(|a| a.seals()).collect();
if paths.is_empty() {
crate::seal::SealSet::default()
} else {
crate::seal::SealSet::capture(&world.repo.root, paths)
}
};
for iter in 0..ctx.policy.max_iters {
self.hooks.fire(&Event::Heartbeat { iter }, world);
let mut budget = self.compactor.budget(&ctx);
if budget.ratio() > self.compaction.high_water {
for stage in CompactionStage::ALL {
if budget.ratio() <= self.compaction.target {
break;
}
self.hooks.fire(&Event::PreCompact { stage }, world);
let before = budget.used;
self.compactor.compact(stage, &mut ctx).await?;
budget = self.compactor.budget(&ctx);
self.hooks.fire(
&Event::PostCompact {
stage,
before,
after: budget.used,
},
world,
);
}
}
for g in &all_guides {
if g.scope().matches(&ctx.task)
&& let Err(e) = g.apply_before_iter(&mut ctx, world).await
{
tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
}
}
self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
let out = if self.streaming {
self.complete_via_stream(&ctx, world).await?
} else {
self.model.complete(&ctx).await?
};
self.hooks.fire(&Event::PostModel { out: &out }, world);
if out.usage.input_tokens > 0 {
let used = self.compactor.budget(&ctx).used;
if used > 0 {
let prev = ctx
.metadata
.get(CALIBRATION_KEY)
.and_then(|v| v.as_f64())
.filter(|f| f.is_finite() && *f > 0.0)
.unwrap_or(1.0);
let next =
(prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
ctx.metadata
.insert(CALIBRATION_KEY.into(), serde_json::json!(next));
}
}
total_usage.input_tokens += out.usage.input_tokens;
total_usage.output_tokens += out.usage.output_tokens;
total_usage.cached_input_tokens += out.usage.cached_input_tokens;
total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
if let Some(t) = &out.text {
last_text = Some(t.clone());
}
ctx.push_model_output(&out);
if self.recall.is_some() {
let calls = if out.tool_calls.is_empty() {
None
} else {
serde_json::to_string(&out.tool_calls).ok()
};
let mut m = harness_core::RecallMessage::new(
"assistant",
out.text.clone().unwrap_or_default(),
world.clock.now_ms(),
);
m.tool_calls = calls;
self.recall_append(&recall_owner, &recall_session, m).await;
}
if out.tool_calls.is_empty() {
let mut verdict: Option<Verdict> = None;
if !self.acceptance.is_empty() {
let mut probe = ctx.clone();
probe.history.push(Turn {
role: TurnRole::Assistant,
blocks: out
.text
.as_deref()
.filter(|t| !t.trim().is_empty())
.map(|t| vec![Block::Text(t.to_string())])
.unwrap_or_default(),
});
for check in &self.acceptance {
let v = check.check(&probe, world).await;
self.hooks.fire(
&Event::AcceptanceChecked {
name: check.name(),
passed: v.passed,
reason: &v.reason,
},
world,
);
if !v.passed {
tracing::info!(
check = check.name(),
reason = %v.reason,
"acceptance failed"
);
verdict = Some(v);
break;
}
}
verdict = verdict.or_else(|| Some(Verdict::passed()));
}
if !sealed_before.is_empty() && verdict.as_ref().is_some_and(|v| v.passed) {
let paths: Vec<std::path::PathBuf> =
self.acceptance.iter().flat_map(|a| a.seals()).collect();
let now = crate::seal::SealSet::capture(&world.repo.root, paths);
let breaches = sealed_before.breaches(&now);
if !breaches.is_empty() {
let what = breaches
.iter()
.map(|b| b.describe())
.collect::<Vec<_>>()
.join("; ");
tracing::error!(
breaches = %what,
"acceptance contract changed during the run — refusing the pass"
);
self.hooks
.fire(&Event::SealBreached { detail: &what }, world);
seal_breached = Some(what);
verdict = Some(Verdict::failed(
"the acceptance contract was modified during this run",
));
}
}
if let Some(v) = verdict.clone().filter(|v| !v.passed)
&& seal_breached.is_none()
&& acceptance_retries_left > 0
&& iter + 1 < ctx.policy.max_iters
{
acceptance_retries_left -= 1;
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(v.reason)],
});
continue;
}
self.hooks.fire(&Event::TaskCompleted, world);
self.hooks.fire(&Event::SessionEnd, world);
self.run_learning_review(&ctx, world, tools_called).await;
let text = out
.text
.filter(|t| !t.trim().is_empty())
.or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
return Ok(Outcome::Done {
text,
iters: iter + 1,
tools_called,
usage: total_usage,
verified: verdict,
contract: sealed_before.clone(),
seal_breach: seal_breached,
});
}
if self.stuck.enabled {
let fp = tool_call_fingerprint(&out.tool_calls);
if last_fingerprint.as_ref() == Some(&fp) {
repeat_count += 1;
} else {
repeat_count = 1;
last_fingerprint = Some(fp);
}
if repeat_count >= self.stuck.abort_after {
let reason =
format!("repeated the same tool call {repeat_count}× without progress");
tracing::warn!(repeated = repeat_count, "stuck: aborting run");
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Stuck {
reason,
repeated: repeat_count,
iters: iter + 1,
last_text,
tools_called,
usage: total_usage,
});
}
if repeat_count == self.stuck.nudge_after {
tracing::warn!(
repeated = repeat_count,
"stuck: nudging model to change approach"
);
ctx.push_feedback(vec![harness_core::Signal {
severity: harness_core::Severity::Warn,
origin: "stuck-detector".into(),
message: format!(
"You have issued the same tool call {repeat_count} rounds in a row \
without making progress."
),
agent_hint: Some(
"Stop repeating it. Inspect the actual tool result/error, try a \
different approach, or give your final answer with no tool call."
.into(),
),
auto_fix: None,
location: None,
}]);
}
}
let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
{
let lead: Vec<&_> = out
.tool_calls
.iter()
.take_while(|c| {
self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
})
.collect();
if lead.len() > 1 {
let futs = lead.iter().map(|c| {
let mut w = world.clone();
let action = Action {
tool: c.name.clone(),
call_id: c.id.clone(),
args: c.args.clone(),
};
async move {
let r = self.dispatch_bounded(&action, &mut w).await;
(action.call_id, r)
}
});
for (id, r) in futures::future::join_all(futs).await {
prefetched.insert(id, r);
}
}
}
for call in &out.tool_calls {
let action = Action {
tool: call.name.clone(),
call_id: call.id.clone(),
args: call.args.clone(),
};
if let HookOutcome::Deny { reason } = self
.hooks
.fire(&Event::PreToolUse { action: &action }, world)
{
ctx.history.push(Turn {
role: TurnRole::Tool,
blocks: vec![Block::ToolResult {
call_id: action.call_id.clone(),
content: serde_json::json!({
"ok": false,
"denied_by_hook": reason,
}),
}],
});
if self.recall.is_some() {
self.recall_append(
&recall_owner,
&recall_session,
harness_core::RecallMessage::new(
"tool",
format!("[denied by hook] {reason}"),
world.clock.now_ms(),
)
.with_tool_name(action.tool.clone()),
)
.await;
}
continue;
}
let result = if let Some(r) = prefetched.remove(&action.call_id) {
r
} else {
self.dispatch_bounded(&action, world).await
};
tools_called += 1;
let result = ToolResult {
content: self.shape_result(&action, &result, &mut answered, &world.repo.root),
..result
};
self.hooks.fire(
&Event::PostToolUse {
action: &action,
result: &result,
},
world,
);
ctx.history.push(Turn {
role: TurnRole::Tool,
blocks: vec![Block::ToolResult {
call_id: action.call_id.clone(),
content: result.content.clone(),
}],
});
if self.recall.is_some() {
let body = serde_json::to_string(&result.content).unwrap_or_default();
self.recall_append(
&recall_owner,
&recall_session,
harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
.with_tool_name(action.tool.clone()),
)
.await;
}
let mut all_signals = Vec::new();
for s in &self.sensors {
if s.stage() != Stage::SelfCorrect {
continue;
}
self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
tracing::warn!(?e, "sensor failed");
Vec::new()
});
self.hooks.fire(
&Event::PostSensor {
sensor: s.id(),
signals: &sigs,
},
world,
);
all_signals.extend(sigs);
}
if !all_signals.is_empty() {
let bundle = SignalSet::new(all_signals);
let (patches, remaining) = bundle.partition_auto_fix();
let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
if !is_default_safe_fix(p) {
tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
return false;
}
match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
HookOutcome::Deny { reason } => {
tracing::warn!(?p, %reason, "auto-fix denied by hook");
self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
false
}
_ => true,
}
}).collect();
let applied = apply_patches(&approved, world).await;
for (i, p) in approved.iter().enumerate() {
self.hooks.fire(
&Event::PostAutoFix {
patch: p,
applied: i < applied.len(),
},
world,
);
}
if !applied.is_empty() {
ctx.push_feedback(vec![harness_core::Signal {
severity: harness_core::Severity::Hint,
origin: "auto-fix".into(),
message: format!(
"applied {} auto-fix patch(es): {applied:?}",
applied.len()
),
agent_hint: Some(
"re-check the affected files before continuing".into(),
),
auto_fix: None,
location: None,
}]);
}
if remaining.has_blocking() {
ctx.push_feedback(remaining.signals);
}
}
}
}
let synthesised = self
.force_final_synthesis(&mut ctx, world, &mut total_usage)
.await;
if let Some(t) = synthesised {
last_text = Some(t);
}
self.hooks.fire(&Event::SessionEnd, world);
self.run_learning_review(&ctx, world, tools_called).await;
Ok(Outcome::BudgetExhausted {
iters: ctx.policy.max_iters,
last_text,
tools_called,
usage: total_usage,
})
}
async fn complete_via_stream(
&self,
ctx: &Context,
world: &mut World,
) -> Result<ModelOutput, HarnessError> {
use futures::StreamExt;
let mut stream = self
.model
.stream(ctx)
.await
.map_err(harness_core::HarnessError::Model)?;
let mut text = String::new();
let mut reasoning = String::new();
let mut usage = Usage::default();
let mut stop_reason = StopReason::EndTurn;
let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
let mut tool_order: Vec<String> = Vec::new();
while let Some(item) = stream.next().await {
let delta = item.map_err(harness_core::HarnessError::Model)?;
match delta {
ModelDelta::Text(t) => {
if !t.is_empty() {
self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
text.push_str(&t);
}
}
ModelDelta::ToolCallStart { id, name } => {
if !tool_starts.contains_key(&id) {
tool_order.push(id.clone());
}
tool_starts
.entry(id)
.or_insert_with(|| (name, String::new()));
}
ModelDelta::ToolCallArgs { id, partial_json } => {
let entry = tool_starts
.entry(id.clone())
.or_insert_with(|| (String::new(), String::new()));
if !tool_order.iter().any(|k| k == &id) {
tool_order.push(id);
}
entry.1.push_str(&partial_json);
}
ModelDelta::ToolCallEnd { .. } => {}
ModelDelta::Usage(u) => usage = u,
ModelDelta::Stop(r) => stop_reason = r,
ModelDelta::Reasoning(s) => {
reasoning.push_str(&s);
}
_ => {}
}
}
let tool_calls: Vec<ToolCall> = tool_order
.into_iter()
.filter_map(|id| {
tool_starts.remove(&id).map(|(name, args)| {
let args_v = serde_json::from_str::<serde_json::Value>(&args)
.unwrap_or(serde_json::Value::String(args));
ToolCall {
id,
name,
args: args_v,
}
})
})
.collect();
let stop_reason = if !tool_calls.is_empty() {
StopReason::ToolUse
} else {
stop_reason
};
Ok(ModelOutput {
text: if text.is_empty() { None } else { Some(text) },
tool_calls,
usage,
stop_reason,
reasoning: if reasoning.is_empty() {
None
} else {
Some(reasoning)
},
images: Vec::new(),
})
}
async fn dispatch_bounded(&self, action: &Action, world: &mut World) -> ToolResult {
let fut = self.tools.dispatch(action, world);
let dispatched = match self.tool_timeout {
Some(deadline) => match tokio::time::timeout(deadline, fut).await {
Ok(r) => r,
Err(_) => {
tracing::warn!(
target: "harness.telemetry",
event = "tool.deadline",
"gen_ai.tool.name" = %action.tool,
seconds = deadline.as_secs(),
);
return ToolResult {
ok: false,
content: serde_json::json!({
"error": format!(
"tool call exceeded its {}s deadline and was cancelled; \
the operation may be too broad — narrow it or try a \
different approach",
deadline.as_secs()
),
"timeout": true,
}),
trace: None,
};
}
},
None => fut.await,
};
dispatched.unwrap_or_else(|e| ToolResult {
ok: false,
content: serde_json::json!({"error": e.to_string()}),
trace: None,
})
}
fn shape_result(
&self,
action: &Action,
result: &ToolResult,
answered: &mut std::collections::HashSet<String>,
root: &std::path::Path,
) -> serde_json::Value {
if !(self.tool_results.dedupe_repeats && result.ok) {
return self.cap_result(action, &result.content, root);
}
match self.tools.risk(&action.tool) {
Some(harness_core::ToolRisk::ReadOnly) => {
let fp = format!("{}({})", action.tool, action.args);
if answered.contains(&fp) {
tracing::info!(
target: "harness.telemetry",
event = "tool.result.repeat",
"gen_ai.tool.name" = %action.tool,
);
serde_json::json!({
"repeat_of_earlier_call": true,
"tool": action.tool,
"note": "You already made this exact call in this run and nothing has \
changed the workspace since. The earlier result above still \
stands — use it rather than asking again.",
})
} else {
answered.insert(fp);
self.cap_result(action, &result.content, root)
}
}
_ => {
answered.clear();
self.cap_result(action, &result.content, root)
}
}
}
fn cap_result(
&self,
action: &Action,
content: &serde_json::Value,
root: &std::path::Path,
) -> serde_json::Value {
let Some(max) = self.tool_results.max_bytes else {
return content.clone();
};
let serialized = content.to_string();
if serialized.len() <= max {
return content.clone();
}
if self.tool_results.spill
&& let Some(marker) = spill_oversized(action, content, &serialized, root)
{
return marker;
}
let mut end = max;
while end > 0 && !serialized.is_char_boundary(end) {
end -= 1;
}
tracing::warn!(
target: "harness.telemetry",
event = "tool.result.truncated",
"gen_ai.tool.name" = %action.tool,
bytes = serialized.len(),
max_bytes = max,
);
serde_json::json!({
"truncated": true,
"tool": action.tool,
"bytes_total": serialized.len(),
"bytes_kept": end,
"head": serialized[..end],
"note": format!(
"This result was {} bytes and was cut to {} to protect the context window. \
Do not ask for it again unchanged — narrow it: request a smaller range, \
a filter, or a specific field.",
serialized.len(), end
),
})
}
async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
if let Some(store) = &self.recall
&& let Err(e) = store.append(owner, session, &msg).await
{
tracing::warn!(error = %e, "recall append failed");
}
}
async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
let Some(cfg) = &self.learning else { return };
if tools_called < cfg.nudge_interval {
return;
}
let transcript = crate::render_transcript(&ctx.history, 12_000);
let task = harness_core::Task {
description: format!(
"{}\n\n## Conversation transcript\n{}",
cfg.review_prompt, transcript
),
source: None,
deadline: None,
};
let mut spec =
crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
for t in &cfg.tools {
spec = spec.with_tool(t.clone());
}
let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
if let Err(e) = Box::pin(sub.run(world)).await {
tracing::warn!(error = %e, "learning review failed");
}
}
async fn force_final_synthesis(
&self,
ctx: &mut Context,
world: &mut World,
total_usage: &mut harness_core::Usage,
) -> Option<String> {
const SYNTHESIS_PROMPT: &str = "[system: iteration budget exhausted] \
You have run out of tool-calling iterations. Write your final answer \
NOW using only the tool results already in this conversation. Do not \
request more tools. Mark facts you could not verify as UNKNOWN. \
Include source URLs for every claim that is not UNKNOWN.";
self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
let saved_tools = std::mem::take(&mut ctx.tools);
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(SYNTHESIS_PROMPT.into())],
});
self.hooks.fire(&Event::PreModel { ctx }, world);
let result = self.model.complete(ctx).await;
ctx.tools = saved_tools;
match result {
Ok(out) => {
self.hooks.fire(&Event::PostModel { out: &out }, world);
total_usage.input_tokens += out.usage.input_tokens;
total_usage.output_tokens += out.usage.output_tokens;
total_usage.cached_input_tokens += out.usage.cached_input_tokens;
total_usage.cache_write_input_tokens += out.usage.cache_write_input_tokens;
ctx.push_model_output(&out);
out.text
}
Err(_) => None,
}
}
}
pub struct Session<'a, M: Model> {
loop_: &'a AgentLoop<M>,
history: Vec<Turn>,
max_iters: u32,
}
impl<'a, M: Model> Session<'a, M> {
pub fn with_max_iters(mut self, n: u32) -> Self {
self.max_iters = n;
self
}
pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
self.history = seed;
self
}
pub fn history(&self) -> &[Turn] {
&self.history
}
pub fn reset(&mut self) {
self.history.clear();
}
pub async fn turn(
&mut self,
message: impl Into<String>,
world: &mut World,
) -> Result<Outcome, HarnessError> {
let message = message.into();
let task = Task {
description: message.clone(),
source: None,
deadline: None,
};
let outcome = self
.loop_
.run_with_seed_history(task, self.history.clone(), world, self.max_iters)
.await?;
let reply = match &outcome {
Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
last_text.clone().unwrap_or_default()
}
};
self.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(message)],
});
self.history.push(Turn {
role: TurnRole::Assistant,
blocks: vec![Block::Text(reply)],
});
Ok(outcome)
}
}
pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
use harness_core::FixPatch;
match patch {
FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
FixPatch::RunCommand { program, args, .. } => match program.as_str() {
"cargo" => matches!(
args.first().map(String::as_str),
Some("fmt" | "clippy" | "fix"),
),
"rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
_ => false,
},
_ => false,
}
}
static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
use harness_core::FixPatch;
let mut applied = Vec::new();
for p in patches {
match p {
FixPatch::ReplaceFile { path, content } => {
let abs = world.repo.root.join(path);
if let Some(parent) = abs.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if tokio::fs::write(&abs, content).await.is_ok() {
applied.push(format!("replaced {}", path.display()));
}
}
FixPatch::UnifiedDiff { diff } => {
if try_apply_diff(world, diff).await {
applied.push("unified diff applied".into());
}
}
FixPatch::RunCommand { program, args, cwd } => {
let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
&& out.status == 0
{
applied.push(format!("ran `{program} {}`", args.join(" ")));
}
}
_ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
}
}
applied
}
async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
use std::sync::atomic::Ordering;
use tokio::io::AsyncWriteExt;
let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let now = world.clock.now_ms();
let tmp = world
.repo
.root
.join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
let mut f = match tokio::fs::File::create(&tmp).await {
Ok(f) => f,
Err(e) => {
tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
return false;
}
};
if let Err(e) = f.write_all(diff.as_bytes()).await {
tracing::warn!(error=%e, "could not write patch tempfile");
let _ = tokio::fs::remove_file(&tmp).await;
return false;
}
drop(f);
let tmp_str = tmp.to_string_lossy().to_string();
let mut applied = false;
for strip in ["-p1", "-p0"] {
match world
.runner
.exec(
"patch",
&[strip, "--silent", "-i", tmp_str.as_str()],
Some(world.repo.root.as_path()),
)
.await
{
Ok(out) if out.status == 0 => {
tracing::info!(strip, "patch applied");
applied = true;
break;
}
Ok(out) => {
tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
}
Err(e) => {
tracing::warn!(error=%e, "patch command not available");
break; }
}
}
let _ = tokio::fs::remove_file(&tmp).await;
applied
}