1pub mod learning;
12pub mod memory_layer;
13#[cfg(feature = "otel")]
14pub mod otel;
15pub mod profile_guide;
16pub mod recall_layer;
17pub mod registry;
18pub mod replay;
19pub mod subagent;
20pub mod telemetry;
21
22pub use learning::*;
23pub use memory_layer::*;
24pub use profile_guide::*;
25pub use recall_layer::*;
26pub use registry::*;
27pub use replay::*;
28pub use subagent::*;
29pub use telemetry::*;
30
31use harness_compactor::{CALIBRATION_KEY, DefaultCompactor};
32use harness_core::{
33 Action, Block, CompactionStage, Compactor, Context, Event, Guide, HarnessError, HookOutcome,
34 Model, ModelDelta, ModelOutput, ResponseFormat, Sensor, SessionSource, SignalSet, Stage,
35 StopReason, Task, ToolCall, ToolResult, Turn, TurnRole, Usage, World,
36};
37use harness_hooks::HookBus;
38use std::collections::HashMap;
39use std::sync::Arc;
40
41#[derive(Debug, Clone)]
49pub struct StuckPolicy {
50 pub enabled: bool,
51 pub nudge_after: u32,
54 pub abort_after: u32,
56}
57
58impl Default for StuckPolicy {
59 fn default() -> Self {
60 Self {
61 enabled: true,
62 nudge_after: 3,
63 abort_after: 6,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
76pub struct CompactPolicy {
77 pub high_water: f32,
79 pub target: f32,
81}
82
83impl Default for CompactPolicy {
84 fn default() -> Self {
85 Self {
86 high_water: 0.75,
87 target: 0.55,
88 }
89 }
90}
91
92fn tool_call_fingerprint(calls: &[ToolCall]) -> String {
96 calls
97 .iter()
98 .map(|c| format!("{}({})", c.name, c.args))
99 .collect::<Vec<_>>()
100 .join("|")
101}
102
103#[derive(Debug, Clone)]
106pub enum Outcome {
107 #[non_exhaustive]
109 Done {
110 text: Option<String>,
111 iters: u32,
112 tools_called: u32,
113 usage: harness_core::Usage,
114 },
115 #[non_exhaustive]
120 BudgetExhausted {
121 iters: u32,
122 last_text: Option<String>,
123 tools_called: u32,
124 usage: harness_core::Usage,
125 },
126 #[non_exhaustive]
131 Stuck {
132 reason: String,
134 repeated: u32,
136 iters: u32,
137 last_text: Option<String>,
138 tools_called: u32,
139 usage: harness_core::Usage,
140 },
141}
142
143pub struct AgentLoop<M: Model> {
145 pub model: M,
146 pub tools: ToolRegistry,
147 pub guides: Vec<Arc<dyn Guide>>,
148 pub sensors: Vec<Arc<dyn Sensor>>,
149 pub hooks: HookBus,
150 pub compactor: Arc<dyn Compactor>,
151 pub response_format: ResponseFormat,
154 pub streaming: bool,
159 pub recall: Option<Arc<dyn harness_core::RecallStore>>,
162 pub recall_auto_inject: bool,
165 pub learning: Option<LearningConfig>,
166 pub stuck: StuckPolicy,
168 pub compaction: CompactPolicy,
170}
171
172impl<M: Model> AgentLoop<M> {
173 pub fn new(model: M) -> Self {
174 Self {
175 model,
176 tools: ToolRegistry::new(),
177 guides: Vec::new(),
178 sensors: Vec::new(),
179 hooks: HookBus::new(),
180 compactor: Arc::new(DefaultCompactor::new()),
181 response_format: ResponseFormat::Free,
182 streaming: false,
183 recall: None,
184 recall_auto_inject: false,
185 learning: None,
186 stuck: StuckPolicy::default(),
187 compaction: CompactPolicy::default(),
188 }
189 }
190
191 pub fn with_stuck_policy(mut self, policy: StuckPolicy) -> Self {
193 self.stuck = policy;
194 self
195 }
196
197 pub fn with_compact_policy(mut self, policy: CompactPolicy) -> Self {
199 self.compaction = policy;
200 self
201 }
202
203 pub fn with_streaming(mut self, enable: bool) -> Self {
207 self.streaming = enable;
208 self
209 }
210
211 pub fn with_compactor(mut self, c: Arc<dyn Compactor>) -> Self {
212 self.compactor = c;
213 self
214 }
215
216 pub fn with_tool(mut self, t: Arc<dyn harness_core::Tool>) -> Self {
217 self.tools.insert(t);
218 self
219 }
220
221 pub fn with_guide(mut self, g: Arc<dyn Guide>) -> Self {
222 self.guides.push(g);
223 self
224 }
225
226 pub fn with_sensor(mut self, s: Arc<dyn Sensor>) -> Self {
227 self.sensors.push(s);
228 self
229 }
230
231 pub fn with_hook(mut self, h: Arc<dyn harness_core::Hook>) -> Self {
232 self.hooks.register(h);
233 self
234 }
235
236 pub fn with_macro_hooks(mut self) -> Self {
238 self.hooks = self.hooks.with_macro_hooks_take();
239 self
240 }
241
242 pub fn with_recall(mut self, store: Arc<dyn harness_core::RecallStore>) -> Self {
246 self.tools
247 .insert(Arc::new(crate::SessionSearchTool::new(store.clone())));
248 self.recall = Some(store);
249 self
250 }
251
252 pub fn auto_inject(mut self) -> Self {
255 self.recall_auto_inject = true;
256 self
257 }
258
259 pub fn with_learning_loop(mut self, cfg: LearningConfig) -> Self {
263 self.learning = Some(cfg);
264 self
265 }
266
267 pub fn with_response_format(mut self, fmt: ResponseFormat) -> Self {
270 self.response_format = fmt;
271 self
272 }
273
274 pub fn with_response_schema(self, name: impl Into<String>, schema: serde_json::Value) -> Self {
278 self.with_response_format(ResponseFormat::JsonSchema {
279 name: name.into(),
280 schema,
281 })
282 }
283
284 pub async fn run(&self, task: Task, world: &mut World) -> Result<Outcome, HarnessError> {
285 let max = harness_core::Policy::default().max_iters;
286 self.run_with_max_iters(task, world, max).await
287 }
288
289 pub async fn run_with_max_iters(
290 &self,
291 task: Task,
292 world: &mut World,
293 max_iters: u32,
294 ) -> Result<Outcome, HarnessError> {
295 self.run_with_seed_history(task, Vec::new(), world, max_iters)
296 .await
297 }
298
299 pub async fn run_typed<T>(&self, task: Task, world: &mut World) -> Result<T, HarnessError>
312 where
313 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
314 {
315 let max = harness_core::Policy::default().max_iters;
316 self.run_typed_with_max_iters::<T>(task, world, max).await
317 }
318
319 pub async fn run_typed_with_max_iters<T>(
321 &self,
322 task: Task,
323 world: &mut World,
324 max_iters: u32,
325 ) -> Result<T, HarnessError>
326 where
327 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
328 {
329 let schema_root = schemars::schema_for!(T);
330 let schema = serde_json::to_value(&schema_root)
331 .map_err(|e| HarnessError::Other(format!("response schema: {e}")))?;
332 let name = std::any::type_name::<T>()
333 .rsplit("::")
334 .next()
335 .unwrap_or("response")
336 .to_string();
337 let fmt = ResponseFormat::JsonSchema { name, schema };
338 let outcome = self
339 .run_with_response_format(task, world, max_iters, fmt)
340 .await?;
341 let text = match outcome {
342 Outcome::Done { text: Some(t), .. }
343 | Outcome::BudgetExhausted {
344 last_text: Some(t), ..
345 }
346 | Outcome::Stuck {
347 last_text: Some(t), ..
348 } => t,
349 Outcome::Done { text: None, .. } => {
350 return Err(HarnessError::Other(
351 "run_typed: model returned no text".into(),
352 ));
353 }
354 Outcome::Stuck {
355 last_text: None, ..
356 } => {
357 return Err(HarnessError::Other(
358 "run_typed: agent stuck with no text".into(),
359 ));
360 }
361 Outcome::BudgetExhausted {
362 last_text: None, ..
363 } => {
364 return Err(HarnessError::Other(
365 "run_typed: budget exhausted with no text".into(),
366 ));
367 }
368 };
369 serde_json::from_str::<T>(&text).map_err(|e| {
370 HarnessError::Other(format!(
371 "run_typed: decode {} failed: {e} — raw text was: {text}",
372 std::any::type_name::<T>()
373 ))
374 })
375 }
376
377 pub async fn run_with_response_format(
379 &self,
380 task: Task,
381 world: &mut World,
382 max_iters: u32,
383 fmt: ResponseFormat,
384 ) -> Result<Outcome, HarnessError> {
385 self.run_with_seed_history_and_format(task, Vec::new(), world, max_iters, Some(fmt))
390 .await
391 }
392
393 async fn run_with_seed_history_and_format(
394 &self,
395 task: Task,
396 seed: Vec<Turn>,
397 world: &mut World,
398 max_iters: u32,
399 fmt_override: Option<ResponseFormat>,
400 ) -> Result<Outcome, HarnessError> {
401 let mut ctx = Context::new(task);
402 ctx.policy.max_iters = max_iters;
403 ctx.tools = self.tools.schemas();
404 ctx.history = seed;
405 ctx.response_format = fmt_override.unwrap_or_else(|| self.response_format.clone());
406 self.run_built_context(ctx, world).await
407 }
408
409 pub async fn run_with_seed_history(
415 &self,
416 task: Task,
417 seed: Vec<Turn>,
418 world: &mut World,
419 max_iters: u32,
420 ) -> Result<Outcome, HarnessError> {
421 self.run_with_seed_and_metadata(task, seed, Default::default(), world, max_iters)
422 .await
423 }
424
425 pub async fn run_with_seed_and_metadata(
433 &self,
434 task: Task,
435 seed: Vec<Turn>,
436 metadata: std::collections::BTreeMap<String, serde_json::Value>,
437 world: &mut World,
438 max_iters: u32,
439 ) -> Result<Outcome, HarnessError> {
440 let mut ctx = Context::new(task);
441 ctx.policy.max_iters = max_iters;
442 ctx.tools = self.tools.schemas();
443 ctx.history = seed;
444 ctx.metadata = metadata;
445 ctx.response_format = self.response_format.clone();
446 self.run_built_context(ctx, world).await
447 }
448
449 pub fn session(&self) -> Session<'_, M> {
457 Session {
458 loop_: self,
459 history: Vec::new(),
460 max_iters: harness_core::Policy::default().max_iters,
461 }
462 }
463
464 async fn run_built_context(
469 &self,
470 mut ctx: Context,
471 world: &mut World,
472 ) -> Result<Outcome, HarnessError> {
473 self.hooks.fire(
474 &Event::SessionStart {
475 source: SessionSource::Startup,
476 },
477 world,
478 );
479
480 let (recall_owner, recall_session) = if self.recall.is_some() {
482 use std::sync::atomic::Ordering;
483 let owner = crate::recall_owner(world);
484 let session = world
485 .profile
486 .extra
487 .get("recall_session")
488 .and_then(|v| v.as_str())
489 .map(|s| s.to_string())
490 .unwrap_or_else(|| {
491 format!(
492 "sess-{}-{}",
493 world.clock.now_ms(),
494 RECALL_SEQ.fetch_add(1, Ordering::SeqCst)
495 )
496 });
497 if let Some(store) = &self.recall {
498 let meta = harness_core::SessionMeta::new(&session, world.clock.now_ms());
499 if let Err(e) = store.ensure_session(&owner, &session, &meta).await {
500 tracing::warn!(error = %e, "recall ensure_session failed");
501 }
502 }
503 (owner, session)
504 } else {
505 (String::new(), String::new())
506 };
507
508 let recall_guide: Option<Arc<dyn Guide>> = if self.recall_auto_inject {
509 if self.recall.is_none() {
510 tracing::warn!(
511 "auto_inject() set but no recall store — call with_recall(store) first; skipping recall guide"
512 );
513 None
514 } else {
515 self.recall
516 .clone()
517 .map(|s| Arc::new(crate::RecallGuide::new(s)) as Arc<dyn Guide>)
518 }
519 } else {
520 None
521 };
522 let all_guides: Vec<&Arc<dyn Guide>> =
523 self.guides.iter().chain(recall_guide.iter()).collect();
524 for g in &all_guides {
525 if g.scope().matches(&ctx.task) {
526 self.hooks.fire(&Event::PreGuide { guide: g.id() }, world);
527 g.apply(&mut ctx, world).await?;
528 self.hooks.fire(&Event::PostGuide { guide: g.id() }, world);
529 }
530 }
531
532 ctx.history.push(Turn {
533 role: TurnRole::User,
534 blocks: vec![Block::Text(ctx.task.description.clone())],
535 });
536
537 if self.recall.is_some() {
538 self.recall_append(
539 &recall_owner,
540 &recall_session,
541 harness_core::RecallMessage::new(
542 "user",
543 ctx.task.description.clone(),
544 world.clock.now_ms(),
545 ),
546 )
547 .await;
548 }
549
550 let mut tools_called: u32 = 0;
552 let mut total_usage = harness_core::Usage::default();
553 let mut last_text: Option<String> = None;
554
555 let mut last_fingerprint: Option<String> = None;
558 let mut repeat_count: u32 = 0;
559
560 for iter in 0..ctx.policy.max_iters {
561 self.hooks.fire(&Event::Heartbeat { iter }, world);
562
563 let mut budget = self.compactor.budget(&ctx);
570 if budget.ratio() > self.compaction.high_water {
571 for stage in CompactionStage::ALL {
572 if budget.ratio() <= self.compaction.target {
573 break;
574 }
575 self.hooks.fire(&Event::PreCompact { stage }, world);
576 self.compactor.compact(stage, &mut ctx).await?;
577 self.hooks.fire(&Event::PostCompact { stage }, world);
578 budget = self.compactor.budget(&ctx);
579 }
580 }
581
582 for g in &all_guides {
588 if g.scope().matches(&ctx.task)
589 && let Err(e) = g.apply_before_iter(&mut ctx, world).await
590 {
591 tracing::warn!(guide = %g.id(), error = %e, "apply_before_iter failed; continuing");
592 }
593 }
594
595 self.hooks.fire(&Event::PreModel { ctx: &ctx }, world);
596 let out = if self.streaming {
597 self.complete_via_stream(&ctx, world).await?
598 } else {
599 self.model.complete(&ctx).await?
600 };
601 self.hooks.fire(&Event::PostModel { out: &out }, world);
602
603 if out.usage.input_tokens > 0 {
611 let used = self.compactor.budget(&ctx).used;
612 if used > 0 {
613 let prev = ctx
614 .metadata
615 .get(CALIBRATION_KEY)
616 .and_then(|v| v.as_f64())
617 .filter(|f| f.is_finite() && *f > 0.0)
618 .unwrap_or(1.0);
619 let next =
620 (prev * out.usage.input_tokens as f64 / used as f64).clamp(0.1, 10.0);
621 ctx.metadata
622 .insert(CALIBRATION_KEY.into(), serde_json::json!(next));
623 }
624 }
625
626 total_usage.input_tokens += out.usage.input_tokens;
628 total_usage.output_tokens += out.usage.output_tokens;
629 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
630 if let Some(t) = &out.text {
631 last_text = Some(t.clone());
632 }
633 ctx.push_model_output(&out);
634
635 if self.recall.is_some() {
636 let calls = if out.tool_calls.is_empty() {
637 None
638 } else {
639 serde_json::to_string(&out.tool_calls).ok()
640 };
641 let mut m = harness_core::RecallMessage::new(
642 "assistant",
643 out.text.clone().unwrap_or_default(),
644 world.clock.now_ms(),
645 );
646 m.tool_calls = calls;
647 self.recall_append(&recall_owner, &recall_session, m).await;
648 }
649
650 if out.tool_calls.is_empty() {
651 self.hooks.fire(&Event::TaskCompleted, world);
652 self.hooks.fire(&Event::SessionEnd, world);
653 self.run_learning_review(&ctx, world, tools_called).await;
654 let text = out
658 .text
659 .filter(|t| !t.trim().is_empty())
660 .or_else(|| out.reasoning.filter(|r| !r.trim().is_empty()));
661 return Ok(Outcome::Done {
662 text,
663 iters: iter + 1,
664 tools_called,
665 usage: total_usage,
666 });
667 }
668
669 if self.stuck.enabled {
674 let fp = tool_call_fingerprint(&out.tool_calls);
675 if last_fingerprint.as_ref() == Some(&fp) {
676 repeat_count += 1;
677 } else {
678 repeat_count = 1;
679 last_fingerprint = Some(fp);
680 }
681
682 if repeat_count >= self.stuck.abort_after {
683 let reason =
684 format!("repeated the same tool call {repeat_count}× without progress");
685 tracing::warn!(repeated = repeat_count, "stuck: aborting run");
686 self.hooks.fire(&Event::SessionEnd, world);
687 return Ok(Outcome::Stuck {
688 reason,
689 repeated: repeat_count,
690 iters: iter + 1,
691 last_text,
692 tools_called,
693 usage: total_usage,
694 });
695 }
696
697 if repeat_count == self.stuck.nudge_after {
698 tracing::warn!(
699 repeated = repeat_count,
700 "stuck: nudging model to change approach"
701 );
702 ctx.push_feedback(vec![harness_core::Signal {
703 severity: harness_core::Severity::Warn,
704 origin: "stuck-detector".into(),
705 message: format!(
706 "You have issued the same tool call {repeat_count} rounds in a row \
707 without making progress."
708 ),
709 agent_hint: Some(
710 "Stop repeating it. Inspect the actual tool result/error, try a \
711 different approach, or give your final answer with no tool call."
712 .into(),
713 ),
714 auto_fix: None,
715 location: None,
716 }]);
717 }
718 }
719
720 let mut prefetched: HashMap<String, ToolResult> = HashMap::new();
727 {
728 let lead: Vec<&_> = out
729 .tool_calls
730 .iter()
731 .take_while(|c| {
732 self.tools.risk(&c.name) == Some(harness_core::ToolRisk::ReadOnly)
733 })
734 .collect();
735 if lead.len() > 1 {
736 let futs =
737 lead.iter().map(|c| {
738 let mut w = world.clone();
739 let action = Action {
740 tool: c.name.clone(),
741 call_id: c.id.clone(),
742 args: c.args.clone(),
743 };
744 async move {
745 let r = self.tools.dispatch(&action, &mut w).await.unwrap_or_else(
746 |e| ToolResult {
747 ok: false,
748 content: serde_json::json!({"error": e.to_string()}),
749 trace: None,
750 },
751 );
752 (action.call_id, r)
753 }
754 });
755 for (id, r) in futures::future::join_all(futs).await {
756 prefetched.insert(id, r);
757 }
758 }
759 }
760
761 for call in &out.tool_calls {
762 let action = Action {
763 tool: call.name.clone(),
764 call_id: call.id.clone(),
765 args: call.args.clone(),
766 };
767
768 if let HookOutcome::Deny { reason } = self
770 .hooks
771 .fire(&Event::PreToolUse { action: &action }, world)
772 {
773 ctx.history.push(Turn {
774 role: TurnRole::Tool,
775 blocks: vec![Block::ToolResult {
776 call_id: action.call_id.clone(),
777 content: serde_json::json!({
778 "ok": false,
779 "denied_by_hook": reason,
780 }),
781 }],
782 });
783 if self.recall.is_some() {
784 self.recall_append(
785 &recall_owner,
786 &recall_session,
787 harness_core::RecallMessage::new(
788 "tool",
789 format!("[denied by hook] {reason}"),
790 world.clock.now_ms(),
791 )
792 .with_tool_name(action.tool.clone()),
793 )
794 .await;
795 }
796 continue;
797 }
798
799 let result = if let Some(r) = prefetched.remove(&action.call_id) {
802 r
803 } else {
804 match self.tools.dispatch(&action, world).await {
805 Ok(r) => r,
806 Err(e) => ToolResult {
807 ok: false,
808 content: serde_json::json!({"error": e.to_string()}),
809 trace: None,
810 },
811 }
812 };
813 tools_called += 1;
814 self.hooks.fire(
815 &Event::PostToolUse {
816 action: &action,
817 result: &result,
818 },
819 world,
820 );
821
822 ctx.history.push(Turn {
823 role: TurnRole::Tool,
824 blocks: vec![Block::ToolResult {
825 call_id: action.call_id.clone(),
826 content: result.content.clone(),
827 }],
828 });
829
830 if self.recall.is_some() {
831 let body = serde_json::to_string(&result.content).unwrap_or_default();
832 self.recall_append(
833 &recall_owner,
834 &recall_session,
835 harness_core::RecallMessage::new("tool", body, world.clock.now_ms())
836 .with_tool_name(action.tool.clone()),
837 )
838 .await;
839 }
840
841 let mut all_signals = Vec::new();
843 for s in &self.sensors {
844 if s.stage() != Stage::SelfCorrect {
845 continue;
846 }
847 self.hooks.fire(&Event::PreSensor { sensor: s.id() }, world);
848 let sigs = s.observe(&action, world).await.unwrap_or_else(|e| {
849 tracing::warn!(?e, "sensor failed");
850 Vec::new()
851 });
852 self.hooks.fire(
853 &Event::PostSensor {
854 sensor: s.id(),
855 signals: &sigs,
856 },
857 world,
858 );
859 all_signals.extend(sigs);
860 }
861 if !all_signals.is_empty() {
862 let bundle = SignalSet::new(all_signals);
863 let (patches, remaining) = bundle.partition_auto_fix();
864
865 let approved: Vec<harness_core::FixPatch> = patches.into_iter().filter(|p| {
869 if !is_default_safe_fix(p) {
870 tracing::warn!(?p, "auto-fix rejected by default safelist (use PreAutoFix hook to override)");
871 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
872 return false;
873 }
874 match self.hooks.fire(&Event::PreAutoFix { patch: p }, world) {
875 HookOutcome::Deny { reason } => {
876 tracing::warn!(?p, %reason, "auto-fix denied by hook");
877 self.hooks.fire(&Event::PostAutoFix { patch: p, applied: false }, world);
878 false
879 }
880 _ => true,
881 }
882 }).collect();
883
884 let applied = apply_patches(&approved, world).await;
885 for (i, p) in approved.iter().enumerate() {
887 self.hooks.fire(
888 &Event::PostAutoFix {
889 patch: p,
890 applied: i < applied.len(),
891 },
892 world,
893 );
894 }
895 if !applied.is_empty() {
896 ctx.push_feedback(vec![harness_core::Signal {
897 severity: harness_core::Severity::Hint,
898 origin: "auto-fix".into(),
899 message: format!(
900 "applied {} auto-fix patch(es): {applied:?}",
901 applied.len()
902 ),
903 agent_hint: Some(
904 "re-check the affected files before continuing".into(),
905 ),
906 auto_fix: None,
907 location: None,
908 }]);
909 }
910 if remaining.has_blocking() {
911 ctx.push_feedback(remaining.signals);
912 }
913 }
914 }
915 }
916 let synthesised = self
926 .force_final_synthesis(&mut ctx, world, &mut total_usage)
927 .await;
928 if let Some(t) = synthesised {
929 last_text = Some(t);
930 }
931
932 self.hooks.fire(&Event::SessionEnd, world);
933 self.run_learning_review(&ctx, world, tools_called).await;
934 Ok(Outcome::BudgetExhausted {
935 iters: ctx.policy.max_iters,
936 last_text,
937 tools_called,
938 usage: total_usage,
939 })
940 }
941
942 async fn complete_via_stream(
952 &self,
953 ctx: &Context,
954 world: &mut World,
955 ) -> Result<ModelOutput, HarnessError> {
956 use futures::StreamExt;
957 let mut stream = self
958 .model
959 .stream(ctx)
960 .await
961 .map_err(harness_core::HarnessError::Model)?;
962 let mut text = String::new();
963 let mut reasoning = String::new();
964 let mut usage = Usage::default();
965 let mut stop_reason = StopReason::EndTurn;
966 let mut tool_starts: HashMap<String, (String, String)> = HashMap::new();
972 let mut tool_order: Vec<String> = Vec::new();
973 while let Some(item) = stream.next().await {
974 let delta = item.map_err(harness_core::HarnessError::Model)?;
975 match delta {
976 ModelDelta::Text(t) => {
977 if !t.is_empty() {
978 self.hooks.fire(&Event::ModelTokenDelta { text: &t }, world);
979 text.push_str(&t);
980 }
981 }
982 ModelDelta::ToolCallStart { id, name } => {
983 if !tool_starts.contains_key(&id) {
984 tool_order.push(id.clone());
985 }
986 tool_starts
987 .entry(id)
988 .or_insert_with(|| (name, String::new()));
989 }
990 ModelDelta::ToolCallArgs { id, partial_json } => {
991 let entry = tool_starts
992 .entry(id.clone())
993 .or_insert_with(|| (String::new(), String::new()));
994 if !tool_order.iter().any(|k| k == &id) {
995 tool_order.push(id);
996 }
997 entry.1.push_str(&partial_json);
998 }
999 ModelDelta::ToolCallEnd { .. } => {}
1000 ModelDelta::Usage(u) => usage = u,
1001 ModelDelta::Stop(r) => stop_reason = r,
1002 ModelDelta::Reasoning(s) => {
1003 reasoning.push_str(&s);
1006 }
1007 _ => {}
1010 }
1011 }
1012 let tool_calls: Vec<ToolCall> = tool_order
1013 .into_iter()
1014 .filter_map(|id| {
1015 tool_starts.remove(&id).map(|(name, args)| {
1016 let args_v = serde_json::from_str::<serde_json::Value>(&args)
1017 .unwrap_or(serde_json::Value::String(args));
1018 ToolCall {
1019 id,
1020 name,
1021 args: args_v,
1022 }
1023 })
1024 })
1025 .collect();
1026 let stop_reason = if !tool_calls.is_empty() {
1030 StopReason::ToolUse
1031 } else {
1032 stop_reason
1033 };
1034 Ok(ModelOutput {
1035 text: if text.is_empty() { None } else { Some(text) },
1036 tool_calls,
1037 usage,
1038 stop_reason,
1039 reasoning: if reasoning.is_empty() {
1040 None
1041 } else {
1042 Some(reasoning)
1043 },
1044 })
1045 }
1046
1047 async fn recall_append(&self, owner: &str, session: &str, msg: harness_core::RecallMessage) {
1049 if let Some(store) = &self.recall
1050 && let Err(e) = store.append(owner, session, &msg).await
1051 {
1052 tracing::warn!(error = %e, "recall append failed");
1053 }
1054 }
1055
1056 async fn run_learning_review(&self, ctx: &Context, world: &mut World, tools_called: u32) {
1058 let Some(cfg) = &self.learning else { return };
1059 if tools_called < cfg.nudge_interval {
1060 return;
1061 }
1062 let transcript = crate::render_transcript(&ctx.history, 12_000);
1063 let task = harness_core::Task {
1064 description: format!(
1065 "{}\n\n## Conversation transcript\n{}",
1066 cfg.review_prompt, transcript
1067 ),
1068 source: None,
1069 deadline: None,
1070 };
1071 let mut spec =
1072 crate::SubagentSpec::new("learning-review", task).with_max_iters(cfg.max_iters);
1073 for t in &cfg.tools {
1074 spec = spec.with_tool(t.clone());
1075 }
1076 let sub = crate::Subagent::new(harness_core::DynModel(cfg.review_model.clone()), spec);
1077 if let Err(e) = Box::pin(sub.run(world)).await {
1082 tracing::warn!(error = %e, "learning review failed");
1083 }
1084 }
1085
1086 async fn force_final_synthesis(
1093 &self,
1094 ctx: &mut Context,
1095 world: &mut World,
1096 total_usage: &mut harness_core::Usage,
1097 ) -> Option<String> {
1098 const SYNTHESIS_PROMPT: &str = "[system: iteration budget exhausted] \
1099 You have run out of tool-calling iterations. Write your final answer \
1100 NOW using only the tool results already in this conversation. Do not \
1101 request more tools. Mark facts you could not verify as UNKNOWN. \
1102 Include source URLs for every claim that is not UNKNOWN.";
1103
1104 self.hooks.fire(&Event::BudgetWarning { ratio: 1.0 }, world);
1109
1110 let saved_tools = std::mem::take(&mut ctx.tools);
1112 ctx.history.push(Turn {
1113 role: TurnRole::User,
1114 blocks: vec![Block::Text(SYNTHESIS_PROMPT.into())],
1115 });
1116
1117 self.hooks.fire(&Event::PreModel { ctx }, world);
1118 let result = self.model.complete(ctx).await;
1119 ctx.tools = saved_tools;
1120
1121 match result {
1122 Ok(out) => {
1123 self.hooks.fire(&Event::PostModel { out: &out }, world);
1124 total_usage.input_tokens += out.usage.input_tokens;
1125 total_usage.output_tokens += out.usage.output_tokens;
1126 total_usage.cached_input_tokens += out.usage.cached_input_tokens;
1127 ctx.push_model_output(&out);
1128 out.text
1129 }
1130 Err(_) => None,
1131 }
1132 }
1133}
1134
1135pub struct Session<'a, M: Model> {
1143 loop_: &'a AgentLoop<M>,
1144 history: Vec<Turn>,
1145 max_iters: u32,
1146}
1147
1148impl<'a, M: Model> Session<'a, M> {
1149 pub fn with_max_iters(mut self, n: u32) -> Self {
1150 self.max_iters = n;
1151 self
1152 }
1153 pub fn with_seed(mut self, seed: Vec<Turn>) -> Self {
1155 self.history = seed;
1156 self
1157 }
1158 pub fn history(&self) -> &[Turn] {
1160 &self.history
1161 }
1162 pub fn reset(&mut self) {
1164 self.history.clear();
1165 }
1166
1167 pub async fn turn(
1171 &mut self,
1172 message: impl Into<String>,
1173 world: &mut World,
1174 ) -> Result<Outcome, HarnessError> {
1175 let message = message.into();
1176 let task = Task {
1177 description: message.clone(),
1178 source: None,
1179 deadline: None,
1180 };
1181 let outcome = self
1182 .loop_
1183 .run_with_seed_history(task, self.history.clone(), world, self.max_iters)
1184 .await?;
1185 let reply = match &outcome {
1186 Outcome::Done { text, .. } => text.clone().unwrap_or_default(),
1187 Outcome::BudgetExhausted { last_text, .. } | Outcome::Stuck { last_text, .. } => {
1188 last_text.clone().unwrap_or_default()
1189 }
1190 };
1191 self.history.push(Turn {
1192 role: TurnRole::User,
1193 blocks: vec![Block::Text(message)],
1194 });
1195 self.history.push(Turn {
1196 role: TurnRole::Assistant,
1197 blocks: vec![Block::Text(reply)],
1198 });
1199 Ok(outcome)
1200 }
1201}
1202
1203pub fn is_default_safe_fix(patch: &harness_core::FixPatch) -> bool {
1215 use harness_core::FixPatch;
1216 match patch {
1217 FixPatch::ReplaceFile { .. } | FixPatch::UnifiedDiff { .. } => true,
1218 FixPatch::RunCommand { program, args, .. } => match program.as_str() {
1219 "cargo" => matches!(
1221 args.first().map(String::as_str),
1222 Some("fmt" | "clippy" | "fix"),
1223 ),
1224 "rustfmt" | "gofmt" | "prettier" | "ruff" | "black" => true,
1225 _ => false,
1226 },
1227 _ => false,
1229 }
1230}
1231
1232static PATCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1235
1236static RECALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1238
1239pub async fn apply_patches(patches: &[harness_core::FixPatch], world: &mut World) -> Vec<String> {
1243 use harness_core::FixPatch;
1244 let mut applied = Vec::new();
1245 for p in patches {
1246 match p {
1247 FixPatch::ReplaceFile { path, content } => {
1248 let abs = world.repo.root.join(path);
1249 if let Some(parent) = abs.parent() {
1250 let _ = tokio::fs::create_dir_all(parent).await;
1251 }
1252 if tokio::fs::write(&abs, content).await.is_ok() {
1253 applied.push(format!("replaced {}", path.display()));
1254 }
1255 }
1256 FixPatch::UnifiedDiff { diff } => {
1257 if try_apply_diff(world, diff).await {
1258 applied.push("unified diff applied".into());
1259 }
1260 }
1261 FixPatch::RunCommand { program, args, cwd } => {
1262 let cwd_ref = cwd.as_deref().unwrap_or(world.repo.root.as_path());
1263 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1264 if let Ok(out) = world.runner.exec(program, &args_ref, Some(cwd_ref)).await
1265 && out.status == 0
1266 {
1267 applied.push(format!("ran `{program} {}`", args.join(" ")));
1268 }
1269 }
1270 _ => tracing::warn!("apply_patches: unknown FixPatch variant — skipped"),
1272 }
1273 }
1274 applied
1275}
1276
1277async fn try_apply_diff(world: &mut World, diff: &str) -> bool {
1282 use std::sync::atomic::Ordering;
1283 use tokio::io::AsyncWriteExt;
1284
1285 let seq = PATCH_SEQ.fetch_add(1, Ordering::SeqCst);
1286 let pid = std::process::id();
1287 let now = world.clock.now_ms();
1288 let tmp = world
1289 .repo
1290 .root
1291 .join(format!(".harness-patch-{pid}-{now}-{seq}.diff"));
1292
1293 let mut f = match tokio::fs::File::create(&tmp).await {
1294 Ok(f) => f,
1295 Err(e) => {
1296 tracing::warn!(error=%e, path=%tmp.display(), "could not create patch tempfile");
1297 return false;
1298 }
1299 };
1300 if let Err(e) = f.write_all(diff.as_bytes()).await {
1301 tracing::warn!(error=%e, "could not write patch tempfile");
1302 let _ = tokio::fs::remove_file(&tmp).await;
1303 return false;
1304 }
1305 drop(f);
1306
1307 let tmp_str = tmp.to_string_lossy().to_string();
1308 let mut applied = false;
1309 for strip in ["-p1", "-p0"] {
1310 match world
1311 .runner
1312 .exec(
1313 "patch",
1314 &[strip, "--silent", "-i", tmp_str.as_str()],
1315 Some(world.repo.root.as_path()),
1316 )
1317 .await
1318 {
1319 Ok(out) if out.status == 0 => {
1320 tracing::info!(strip, "patch applied");
1321 applied = true;
1322 break;
1323 }
1324 Ok(out) => {
1325 tracing::debug!(strip, stderr=%out.stderr, "patch failed; trying next strip level");
1326 }
1327 Err(e) => {
1328 tracing::warn!(error=%e, "patch command not available");
1329 break; }
1331 }
1332 }
1333 let _ = tokio::fs::remove_file(&tmp).await;
1334 applied
1335}