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