pub mod learning;
pub mod memory_layer;
#[cfg(feature = "otel")]
pub mod otel;
pub mod profile_guide;
pub mod recall_layer;
pub mod registry;
pub mod replay;
pub mod subagent;
pub mod telemetry;
pub use learning::*;
pub use memory_layer::*;
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;
#[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 CompactPolicy {
pub high_water: f32,
pub target: f32,
}
impl Default for CompactPolicy {
fn default() -> Self {
Self {
high_water: 0.75,
target: 0.55,
}
}
}
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,
},
#[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 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,
}
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()),
response_format: ResponseFormat::Free,
streaming: false,
recall: None,
recall_auto_inject: false,
learning: None,
stuck: StuckPolicy::default(),
compaction: CompactPolicy::default(),
}
}
pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
self.stuck = 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_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 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_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> {
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;
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);
self.compactor.compact(stage, &mut ctx).await?;
self.hooks.fire(&Event::PostCompact { stage }, world);
budget = self.compactor.budget(&ctx);
}
}
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;
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() {
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,
});
}
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.tools.dispatch(&action, &mut w).await.unwrap_or_else(
|e| ToolResult {
ok: false,
content: serde_json::json!({"error": e.to_string()}),
trace: None,
},
);
(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 {
match self.tools.dispatch(&action, world).await {
Ok(r) => r,
Err(e) => ToolResult {
ok: false,
content: serde_json::json!({"error": e.to_string()}),
trace: None,
},
}
};
tools_called += 1;
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)
},
})
}
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;
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
}