pub mod blueprint;
pub mod compactor;
pub mod daemon;
pub mod experience;
pub mod hooks;
pub mod loop_engine;
pub mod orchestrator;
pub mod sandbox;
pub mod scheduler;
pub mod templates;
pub mod acceptance;
pub mod goal;
pub mod learning;
pub mod memory_layer;
pub mod meters;
#[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 use tokio_util::sync::CancellationToken;
pub mod replay;
pub mod subagent;
pub mod telemetry;
pub mod timeline;
pub mod trajectory;
pub use trajectory::{
GoldenDiff, Report, Rule, RuleSet, Step, Trajectory, TrajectoryCheck, Violation,
};
pub use learning::*;
pub use memory_layer::*;
pub mod boundary_guide;
pub use boundary_guide::*;
pub mod events;
pub use events::{HostEvent, NoticeGuide};
pub use profile_guide::*;
pub use recall_layer::*;
pub use registry::*;
pub use replay::*;
pub use subagent::*;
pub use telemetry::*;
use crate::compactor::{CALIBRATION_KEY, DefaultCompactor};
use crate::hooks::HookBus;
use harness_core::{
Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage, Task,
ToolCall, ToolResult, Turn, TurnRole, World,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tracing::Instrument;
use tracing::field::Empty;
fn finish_chat(
span: &tracing::Span,
info: &harness_core::ModelInfo,
started: std::time::Instant,
result: Option<Result<&ModelOutput, String>>,
) {
let end = match &result {
Some(Ok(out)) => {
span.record("gen_ai.usage.input_tokens", out.usage.input_tokens)
.record("gen_ai.usage.output_tokens", out.usage.output_tokens)
.record(
"gen_ai.usage.cached_input_tokens",
out.usage.cached_input_tokens,
)
.record(
"gen_ai.response.finish_reasons",
format!("{:?}", out.stop_reason),
)
.record("tool_calls", out.tool_calls.len());
meters::ModelCallEnd::Done(&out.usage)
}
Some(Err(e)) => {
span.record("otel.status_code", "error")
.record("otel.status_message", e.as_str());
meters::ModelCallEnd::Failed
}
None => {
span.record("otel.status_message", "cancelled");
meters::ModelCallEnd::Cancelled
}
};
meters::model_call(info, started.elapsed(), end);
}
struct Dispatched {
result: ToolResult,
started_ms: i64,
finished_ms: i64,
}
fn chat_span(info: &harness_core::ModelInfo) -> tracing::Span {
tracing::info_span!(
target: "harness.telemetry",
"chat",
"otel.name" = %format!("chat {}", info.model),
"otel.kind" = "client",
"otel.status_code" = Empty,
"otel.status_message" = Empty,
"gen_ai.operation.name" = "chat",
"gen_ai.provider.name" = %info.provider,
"gen_ai.request.model" = %info.model,
"gen_ai.usage.input_tokens" = Empty,
"gen_ai.usage.output_tokens" = Empty,
"gen_ai.usage.cached_input_tokens" = Empty,
"gen_ai.response.finish_reasons" = Empty,
tool_calls = Empty,
)
}
#[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 MonotonyPolicy {
pub enabled: bool,
pub nudge_after: u32,
pub abort_after: u32,
}
impl Default for MonotonyPolicy {
fn default() -> Self {
Self {
enabled: false,
nudge_after: 8,
abort_after: 16,
}
}
}
#[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 MODEL_PROGRESS_EVERY: Duration = Duration::from_secs(5);
const MODEL_HEARTBEAT_EVERY: Duration = Duration::from_secs(30);
const MODEL_TRANSPORT_RETRIES: u32 = 2;
const MAX_TRUNCATED_TURNS: u32 = 2;
const TRUNCATED_TURN_NUDGE: &str = "Your previous turn hit the output token limit before \
you produced an answer or a tool call, so nothing from it was used. Do not repeat that \
reasoning. Act now: either call the next tool you need, or write the final answer \
directly and keep it short.";
const SPILL_PREVIEW_BYTES: usize = 4 * 1024;
const BUDGET_SYNTHESIS_PROMPT: &str = "[system: budget exhausted] \
You have run out of budget for tool calls. 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.";
const MONOTONY_SYNTHESIS_PROMPT: &str = "[system: stopped — no progress] \
You have called the same tool many rounds in a row without using any other \
tool, so no further tool calls will be granted. Write your final answer NOW \
using only the tool results already in this conversation. Do not request \
more tools. State plainly which parts you could not determine instead of \
guessing at them.";
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("|")
}
fn sole_tool(calls: &[ToolCall]) -> Option<&str> {
let first = calls.first()?.name.as_str();
calls.iter().all(|c| c.name == first).then_some(first)
}
fn result_digest(tool: &str, content: &serde_json::Value) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
tool.hash(&mut h);
content.to_string().hash(&mut h);
h.finish()
}
#[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,
deadline_reached: bool,
},
#[non_exhaustive]
Stuck {
reason: String,
repeated: u32,
iters: u32,
last_text: Option<String>,
tools_called: u32,
usage: harness_core::Usage,
},
#[non_exhaustive]
Cancelled {
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 cancel: CancellationToken,
pub recall: Option<Arc<dyn harness_core::RecallStore>>,
pub recall_auto_inject: bool,
pub learning: Option<LearningConfig>,
pub stuck: StuckPolicy,
pub monotony: MonotonyPolicy,
pub max_input_tokens: Option<u32>,
pub compaction: CompactPolicy,
pub tool_results: ToolResultPolicy,
pub acceptance: Vec<Arc<dyn Acceptance>>,
pub acceptance_retries: u32,
pub system: Vec<Block>,
pub model_roles: std::collections::HashMap<String, Arc<dyn Model>>,
pub compactor_custom: bool,
}
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: true,
cancel: CancellationToken::new(),
recall: None,
recall_auto_inject: false,
learning: None,
stuck: StuckPolicy::default(),
monotony: MonotonyPolicy::default(),
max_input_tokens: None,
compaction: CompactPolicy::default(),
tool_results: ToolResultPolicy::default(),
acceptance: vec![Arc::new(acceptance::NonEmptyAnswer)],
acceptance_retries: 1,
system: Vec::new(),
model_roles: std::collections::HashMap::new(),
compactor_custom: false,
}
}
pub fn with_model_role(mut self, role: impl Into<String>, model: Arc<dyn Model>) -> Self {
let role = role.into();
if role == "compactor" && !self.compactor_custom {
self.compactor = Arc::new(crate::compactor::ModelBackedCompactor::new(model.clone()));
}
self.model_roles.insert(role, model);
self
}
pub fn model_for(&self, role: &str) -> Option<Arc<dyn Model>> {
self.model_roles.get(role).cloned()
}
pub fn with_system(mut self, text: impl Into<String>) -> Self {
self.system = vec![Block::Text(text.into())];
self
}
pub fn with_max_input_tokens(mut self, n: u32) -> Self {
self.max_input_tokens = Some(n);
self
}
pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
self.stuck = policy;
self
}
pub fn with_monotony_policy(mut self, policy: MonotonyPolicy) -> Self {
self.monotony = 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_cancellation(mut self, token: CancellationToken) -> Self {
self.cancel = token;
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.compactor_custom = true;
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), ..
}
| Outcome::Cancelled {
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(),
));
}
Outcome::Cancelled {
last_text: None, ..
} => {
return Err(HarnessError::Other(
"run_typed: run cancelled 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,
ctx: Context,
world: &mut World,
) -> Result<Outcome, HarnessError> {
let info = self.model.info();
let span = tracing::info_span!(
target: "harness.telemetry",
"agent_run",
"gen_ai.operation.name" = "invoke_agent",
"gen_ai.provider.name" = %info.provider,
"gen_ai.request.model" = %info.model,
outcome = Empty,
"otel.status_code" = Empty,
"otel.status_message" = Empty,
);
let started = std::time::Instant::now();
let r = self.run_in_span(ctx, world).instrument(span.clone()).await;
let outcome = match &r {
Ok(Outcome::Done { .. }) => "done",
Ok(Outcome::BudgetExhausted { .. }) => "budget_exhausted",
Ok(Outcome::Stuck { .. }) => "stuck",
Ok(Outcome::Cancelled { .. }) => "cancelled",
Err(e) => {
span.record("otel.status_code", "error")
.record("otel.status_message", e.to_string());
"error"
}
};
span.record("outcome", outcome);
meters::run(outcome, started.elapsed());
r
}
async fn run_in_span(
&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 let Some(n) = self.max_input_tokens {
ctx.policy.max_input_tokens = n.max(1);
} else 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 monotone_tool: Option<String> = None;
let mut monotone_rounds: u32 = 0;
let mut answered: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut acceptance_retries_left = self.acceptance_retries;
let mut truncated_turns: u32 = 0;
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)
}
};
let mut iters_done = ctx.policy.max_iters;
let mut deadline_reached = false;
for iter in 0..ctx.policy.max_iters {
if self.cancel.is_cancelled() {
tracing::info!(iter, "run cancelled by caller");
self.hooks.fire(&Event::Cancelled, world);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Cancelled {
iters: iter,
last_text,
tools_called,
usage: total_usage,
});
}
if let Some(at) = ctx.task.deadline
&& world.clock.now_ms() >= at
{
tracing::warn!(
iter,
deadline = at,
"task deadline reached — forcing a final answer"
);
iters_done = iter;
deadline_reached = true;
break;
}
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;
let turns_before = ctx.history.len();
self.compactor.compact(stage, &mut ctx).await?;
budget = self.compactor.budget(&ctx);
tracing::info!(
stage = ?stage,
tokens_before = before,
tokens_after = budget.used,
turns_before,
turns_after = ctx.history.len(),
"compacted context"
);
meters::compaction(stage, before, budget.used);
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 mut partial = String::new();
let Some(out) = self.model_step(&ctx, world, &mut partial, iter).await? else {
tracing::info!(iter, "run cancelled during model step");
if !partial.is_empty() {
last_text = Some(std::mem::take(&mut partial));
}
self.hooks.fire(&Event::Cancelled, world);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Cancelled {
iters: iter + 1,
last_text,
tools_called,
usage: total_usage,
});
};
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() && out.stop_reason == harness_core::StopReason::MaxTokens {
truncated_turns += 1;
tracing::warn!(
truncated_turns,
max_output_tokens = ctx.policy.max_output_tokens,
"model turn hit the output token limit without a tool call"
);
if truncated_turns <= MAX_TRUNCATED_TURNS && iter + 1 < ctx.policy.max_iters {
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(TRUNCATED_TURN_NUDGE.into())],
});
continue;
}
let reason = format!(
"the model hit the output token limit ({} tokens) {truncated_turns} turn(s) \
in a row without producing an answer or a tool call",
ctx.policy.max_output_tokens
);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Stuck {
reason,
repeated: truncated_turns,
iters: iter + 1,
last_text: out.text.filter(|t| !t.trim().is_empty()),
tools_called,
usage: total_usage,
});
}
truncated_turns = 0;
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,
});
}
let mut round_results: Vec<u64> = Vec::new();
let mut prefetched: HashMap<String, Dispatched> = 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)
}
});
let results = tokio::select! {
biased;
_ = self.cancel.cancelled() => Vec::new(),
rs = futures::future::join_all(futs) => rs,
};
for (id, r) in results {
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 Dispatched {
result,
started_ms,
finished_ms,
} = if let Some(r) = prefetched.remove(&action.call_id) {
r
} else {
self.dispatch_bounded(&action, world).await
};
tools_called += 1;
if self.cancel.is_cancelled() {
tracing::info!(iter, "run cancelled during tool dispatch");
self.hooks.fire(&Event::Cancelled, world);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Cancelled {
iters: iter + 1,
last_text,
tools_called,
usage: total_usage,
});
}
let result = ToolResult {
content: self.shape_result(&action, &result, &mut answered, &world.repo.root),
..result
};
self.hooks.fire(
&Event::ToolTimed {
action: &action,
started_ms,
finished_ms,
},
world,
);
self.hooks.fire(
&Event::PostToolUse {
action: &action,
result: &result,
},
world,
);
round_results.push(result_digest(&action.tool, &result.content));
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);
}
}
}
if self.stuck.enabled {
let fp = format!(
"{}#{}",
tool_call_fingerprint(&out.tool_calls),
round_results
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(",")
);
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}× with an unchanging result"
);
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 \
and the result has not changed."
),
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. \
If you are waiting on something, wait inside one call rather than \
polling in a tight loop."
.into(),
),
auto_fix: None,
location: None,
}]);
}
}
if self.monotony.enabled {
match sole_tool(&out.tool_calls) {
Some(name) if monotone_tool.as_deref() == Some(name) => monotone_rounds += 1,
Some(name) => {
monotone_tool = Some(name.to_string());
monotone_rounds = 1;
}
None => {
monotone_tool = None;
monotone_rounds = 0;
}
}
if monotone_rounds >= self.monotony.abort_after {
let name = monotone_tool.clone().unwrap_or_default();
let reason = format!(
"called `{name}` for {monotone_rounds} consecutive rounds \
without using any other tool"
);
tracing::warn!(tool = %name, rounds = monotone_rounds, "monotony: aborting run");
match self
.force_final_synthesis(
&mut ctx,
world,
&mut total_usage,
MONOTONY_SYNTHESIS_PROMPT,
)
.await
{
Synthesis::Text(Some(t)) => last_text = Some(t),
Synthesis::Text(None) => {}
Synthesis::Cancelled => {
tracing::info!(iter, "run cancelled during final synthesis");
self.hooks.fire(&Event::Cancelled, world);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Cancelled {
iters: iter + 1,
last_text,
tools_called,
usage: total_usage,
});
}
}
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Stuck {
reason,
repeated: monotone_rounds,
iters: iter + 1,
last_text,
tools_called,
usage: total_usage,
});
}
if monotone_rounds == self.monotony.nudge_after {
let name = monotone_tool.clone().unwrap_or_default();
tracing::warn!(
tool = %name,
rounds = monotone_rounds,
"monotony: nudging model to change approach"
);
ctx.push_feedback(vec![harness_core::Signal {
severity: harness_core::Severity::Warn,
origin: "monotony-detector".into(),
message: format!(
"You have called `{name}` for {monotone_rounds} rounds in a row \
and used no other tool. A different argument each time is not \
the same thing as making progress."
),
agent_hint: Some(
"If you are guessing at arguments to see which one lands, stop: \
report what you did find and say plainly what you could not \
determine. Otherwise reach for a different tool, or give your \
final answer with no tool call."
.into(),
),
auto_fix: None,
location: None,
}]);
}
}
}
self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
match self
.force_final_synthesis(&mut ctx, world, &mut total_usage, BUDGET_SYNTHESIS_PROMPT)
.await
{
Synthesis::Text(Some(t)) => last_text = Some(t),
Synthesis::Text(None) => {}
Synthesis::Cancelled => {
tracing::info!(iter = iters_done, "run cancelled during final synthesis");
self.hooks.fire(&Event::Cancelled, world);
self.hooks.fire(&Event::SessionEnd, world);
return Ok(Outcome::Cancelled {
iters: iters_done,
last_text,
tools_called,
usage: total_usage,
});
}
}
self.hooks.fire(&Event::SessionEnd, world);
self.run_learning_review(&ctx, world, tools_called).await;
Ok(Outcome::BudgetExhausted {
iters: iters_done,
last_text,
tools_called,
usage: total_usage,
deadline_reached,
})
}
async fn model_step(
&self,
ctx: &Context,
world: &mut World,
partial: &mut String,
iter: u32,
) -> Result<Option<ModelOutput>, HarnessError> {
let info = self.model.info();
let span = chat_span(&info);
let started = std::time::Instant::now();
let r = async {
tokio::select! {
biased;
_ = self.cancel.cancelled() => Ok(None),
r = self.model_call(ctx, world, partial, iter) => r.map(Some),
}
}
.instrument(span.clone())
.await;
let end = match &r {
Ok(Some(out)) => Some(Ok(out)),
Ok(None) => None,
Err(e) => Some(Err(e.to_string())),
};
finish_chat(&span, &info, started, end);
r
}
async fn model_call(
&self,
ctx: &Context,
world: &mut World,
partial: &mut String,
iter: u32,
) -> Result<ModelOutput, HarnessError> {
let mut attempt: u32 = 0;
loop {
let r = if self.streaming {
self.complete_via_stream(ctx, world, partial, iter).await
} else {
self.model
.complete(ctx)
.await
.map_err(harness_core::HarnessError::Model)
};
match r {
Err(HarnessError::Model(harness_core::ModelError::Transport(msg)))
if attempt < MODEL_TRANSPORT_RETRIES =>
{
attempt += 1;
tracing::warn!(
attempt,
error = %msg,
"model call failed in transport; retrying the turn"
);
partial.clear();
tokio::time::sleep(Duration::from_secs(u64::from(attempt) * 2)).await;
}
other => return other,
}
}
}
async fn complete_via_stream(
&self,
ctx: &Context,
world: &mut World,
sink: &mut String,
iter: u32,
) -> Result<ModelOutput, HarnessError> {
let stream = self
.model
.stream(ctx)
.await
.map_err(harness_core::HarnessError::Model)?;
let started = std::time::Instant::now();
let mut last_progress = started;
let mut last_heartbeat = started;
let (mut chars, mut deltas) = (0usize, 0usize);
let hooks = &self.hooks;
let out = harness_core::collect_stream_observed(stream, |delta| {
deltas += 1;
match delta {
ModelDelta::Text(t) if !t.is_empty() => {
chars += t.chars().count();
hooks.fire(&Event::ModelTokenDelta { text: t }, world);
sink.push_str(t);
}
ModelDelta::Reasoning(t) => chars += t.chars().count(),
ModelDelta::ToolCallArgs { partial_json, .. } => {
chars += partial_json.chars().count()
}
_ => {}
}
let now = std::time::Instant::now();
if now.duration_since(last_heartbeat) >= MODEL_HEARTBEAT_EVERY {
last_heartbeat = now;
hooks.fire(&Event::Heartbeat { iter }, world);
}
if now.duration_since(last_progress) >= MODEL_PROGRESS_EVERY {
last_progress = now;
let elapsed = now.duration_since(started).as_secs_f64();
let data = serde_json::json!({
"elapsed_s": elapsed,
"chars": chars,
"deltas": deltas,
"chars_per_s": if elapsed > 0.0 { chars as f64 / elapsed } else { 0.0 },
});
hooks.fire(
&Event::Custom {
name: "model_progress",
data: &data,
},
world,
);
}
})
.await
.map_err(harness_core::HarnessError::Model)?;
sink.clear();
Ok(out)
}
async fn dispatch_bounded(&self, action: &Action, world: &mut World) -> Dispatched {
let span = tracing::info_span!(
target: "harness.telemetry",
"execute_tool",
"otel.name" = %format!("execute_tool {}", action.tool),
"otel.status_code" = Empty,
"otel.status_message" = Empty,
"gen_ai.operation.name" = "execute_tool",
"gen_ai.tool.name" = %action.tool,
"gen_ai.tool.call.id" = %action.call_id,
ok = Empty,
);
let clock = world.clock.clone();
let started_ms = clock.now_ms();
let started = std::time::Instant::now();
let r = self
.dispatch_bounded_in_span(action, world)
.instrument(span.clone())
.await;
let finished_ms = clock.now_ms();
meters::tool_call(&action.tool, r.ok, started.elapsed());
span.record("ok", r.ok);
if !r.ok {
span.record("otel.status_code", "error");
if let Some(msg) = r.content.get("error").and_then(|v| v.as_str()) {
span.record("otel.status_message", msg);
}
}
Dispatched {
result: r,
started_ms,
finished_ms,
}
}
async fn dispatch_bounded_in_span(&self, action: &Action, world: &mut World) -> ToolResult {
let fut = self.tools.dispatch(action, world);
let bounded = async {
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(),
);
Ok(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,
}
};
let dispatched = tokio::select! {
biased;
_ = self.cancel.cancelled() => {
tracing::info!("gen_ai.tool.name" = %action.tool, "tool call dropped: run cancelled");
return ToolResult {
ok: false,
content: serde_json::json!({"error": "run cancelled", "cancelled": true}),
trace: None,
};
}
r = bounded => r,
};
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,
prompt: &str,
) -> Synthesis {
let saved_tools = std::mem::take(&mut ctx.tools);
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(prompt.into())],
});
self.hooks.fire(&Event::PreModel { ctx }, world);
let info = self.model.info();
let span = chat_span(&info);
let started = std::time::Instant::now();
let mut partial = String::new();
let iter = ctx.policy.max_iters;
let result = tokio::select! {
biased;
_ = self.cancel.cancelled() => None,
r = self
.model_call(ctx, world, &mut partial, iter)
.instrument(span.clone()) => Some(r),
};
finish_chat(
&span,
&info,
started,
result
.as_ref()
.map(|r| r.as_ref().map_err(|e| e.to_string())),
);
ctx.tools = saved_tools;
let Some(result) = result else {
ctx.history.pop();
return Synthesis::Cancelled;
};
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);
Synthesis::Text(out.text)
}
Err(_) => Synthesis::Text(None),
}
}
}
enum Synthesis {
Text(Option<String>),
Cancelled,
}
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, .. }
| Outcome::Cancelled { 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
}