1#![allow(unused_doc_comments)]
2
3const TTSR_INTERRUPT_TEMPLATE: &str = include_str!("../prompts/ttsr-interrupt.md");
16
17pub mod append_only;
19pub mod config;
21pub mod helpers;
23pub mod queues;
25pub mod retry;
27pub mod stream_outcome;
29pub mod streaming;
31pub mod tool_exec;
33pub mod ttsr;
35
36use crate::agent::ProviderResolver;
38use crate::compaction::{CompactedContext, CompactionEvent};
39use crate::events::AgentEvent;
40use crate::state::TokenSource;
41use crate::{state::SharedState, tools::ToolContext, tools::ToolRegistry};
42use anyhow::{Error, Result};
43pub use config::{AfterToolCallHook, AgentLoopConfig, BeforeToolCallHook, ToolExecutionMode};
44use oxi_ai::{
45 CompactionManager as OxCompactionManager, CompactionStrategy, ContentBlock, LlmCompactor,
46 Message, Provider, StopReason, TextContent, UserMessage,
47};
48use parking_lot::RwLock;
49use std::sync::Arc;
50use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
51use std::time::Instant;
52
53use self::helpers::{sanitize_orphaned_tool_results, should_stop_after_turn};
54use self::queues::{
55 clear_all_queues, clear_follow_up_queue, clear_steering_queue, drain_follow_up_queue,
56 drain_steering_queue, try_push_follow_up, try_push_steering,
57};
58use self::retry::{
59 auto_retry_attempt_method, cancel_auto_retry, handle_retryable_error, is_retryable_error,
60};
61use self::streaming::stream_assistant_response;
62use self::tool_exec::execute_tool_calls;
63
64pub use self::stream_outcome::StreamOutcome;
65type EmitFn = Arc<dyn Fn(AgentEvent) + Send + Sync>;
66
67pub struct AgentLoop {
69 provider: Arc<dyn Provider>,
70 config: AgentLoopConfig,
71 tools: Arc<ToolRegistry>,
72 state: SharedState,
73 compaction_manager: OxCompactionManager,
74 before_tool_call: Option<BeforeToolCallHook>,
75 after_tool_call: Option<AfterToolCallHook>,
76 steering_queue: RwLock<Vec<Message>>,
77 follow_up_queue: RwLock<Vec<Message>>,
78 session_id: Option<String>,
79 auto_retry_attempt: AtomicUsize,
80 auto_retry_cancel: AtomicBool,
81 auto_retry_notify: tokio::sync::Notify,
83 external_stop: Arc<AtomicBool>,
86 cancel_signal: Option<Arc<AtomicBool>>,
90 auto_retry_enabled_override: Option<Arc<AtomicBool>>,
94 auto_retry_cancel_signal: Option<Arc<AtomicBool>>,
96 auto_retry_notify_signal: Option<Arc<tokio::sync::Notify>>,
98 resolver: Arc<dyn ProviderResolver>,
100 steering_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
103 follow_up_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
105 ttsr_engine: Option<Arc<ttsr::TtsrEngine>>,
107 thinking_loop_detector:
112 parking_lot::Mutex<Option<oxi_ai::utils::thinking_loop::ThinkingLoopDetector>>,
113 tool_call_loop_guard: parking_lot::Mutex<oxi_ai::utils::tool_call_loop::ToolCallLoopGuard>,
118 soft_requirement_state: parking_lot::Mutex<crate::agent_loop::config::SoftRequirementState>,
120}
121
122impl AgentLoop {
123 pub fn new_with_resolver(
127 provider: Arc<dyn Provider>,
128 config: AgentLoopConfig,
129 tools: Arc<ToolRegistry>,
130 state: SharedState,
131 resolver: Arc<dyn ProviderResolver>,
132 ) -> Self {
133 let mut compaction_manager =
134 OxCompactionManager::new(config.compaction_strategy.clone(), config.context_window);
135
136 if config.compaction_strategy != CompactionStrategy::Disabled {
137 let model = resolver.resolve_model(&config.model_id);
138 if let Some(model) = model {
139 let llm_compactor =
140 Arc::new(LlmCompactor::new(model.clone(), Arc::clone(&provider)));
141 compaction_manager.set_compactor(llm_compactor);
142 }
143 }
144
145 Self {
146 provider,
147 config: config.clone(),
148 tools,
149 state,
150 compaction_manager,
151 before_tool_call: None,
152 after_tool_call: None,
153 steering_queue: RwLock::new(Vec::new()),
154 follow_up_queue: RwLock::new(Vec::new()),
155 session_id: config.session_id.clone(),
156 auto_retry_attempt: AtomicUsize::new(0),
157 auto_retry_cancel: AtomicBool::new(false),
158 auto_retry_notify: tokio::sync::Notify::new(),
159 external_stop: Arc::new(AtomicBool::new(false)),
160 cancel_signal: None,
161 auto_retry_enabled_override: None,
162 auto_retry_cancel_signal: None,
163 auto_retry_notify_signal: None,
164 resolver,
165 steering_hook: None,
166 follow_up_hook: None,
167 ttsr_engine: config.ttsr_engine.clone(),
168 thinking_loop_detector: parking_lot::Mutex::new(if config.thinking_loop_detection {
169 Some(oxi_ai::utils::thinking_loop::ThinkingLoopDetector::new())
170 } else {
171 None
172 }),
173 tool_call_loop_guard: parking_lot::Mutex::new(
174 oxi_ai::utils::tool_call_loop::ToolCallLoopGuard::new(
175 config.tool_call_loop_guard.clone(),
176 ),
177 ),
178 soft_requirement_state: parking_lot::Mutex::new(
179 crate::agent_loop::config::SoftRequirementState::default(),
180 ),
181 }
182 }
183
184 pub fn new(
186 provider: Arc<dyn Provider>,
187 config: AgentLoopConfig,
188 tools: Arc<ToolRegistry>,
189 state: SharedState,
190 ) -> Self {
191 use crate::agent::GlobalProviderResolver;
192 Self::new_with_resolver(
193 provider,
194 config,
195 tools,
196 state,
197 Arc::new(GlobalProviderResolver),
198 )
199 }
200
201 pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
204 self.before_tool_call = Some(hook);
205 self
206 }
207
208 pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
211 self.after_tool_call = Some(hook);
212 self
213 }
214
215 pub fn steer(&self, message: Message) {
221 if !try_push_steering(self, message) {
222 tracing::warn!("Steering message dropped — queue at capacity");
223 }
224 }
225
226 pub fn follow_up(&self, message: Message) {
232 if !try_push_follow_up(self, message) {
233 tracing::warn!("Follow-up message dropped — queue at capacity");
234 }
235 }
236
237 pub fn clear_steering_queue(&self) {
240 clear_steering_queue(self);
241 }
242
243 pub fn clear_follow_up_queue(&self) {
246 clear_follow_up_queue(self);
247 }
248
249 pub fn clear_all_queues(&self) {
251 clear_all_queues(self);
252 }
253
254 fn drain_steering_queue(&self) -> Vec<Message> {
255 drain_steering_queue(self)
256 }
257
258 #[cfg_attr(test, allow(dead_code))]
261 pub(crate) fn build_tool_context(&self) -> ToolContext {
262 let workspace = self
263 .config
264 .workspace_dir
265 .clone()
266 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
267 ToolContext {
268 workspace_dir: workspace,
269 root_dir: self.config.workspace_dir.clone(),
270 session_id: self.session_id.clone(),
271 snapshot_store: self.config.snapshot_store.clone(),
272 memory: self.config.memory.clone(),
273 url_resolver: self.config.url_resolver.clone(),
274 todo: self.config.todo.clone(),
275 agent_pool: self.config.agent_pool.clone(),
276 lsp: self.config.lsp.clone(),
277 subagent_runner: self.config.subagent_runner.clone(),
278 subagent_depth: self.config.subagent_depth,
279 intent: None,
280 }
281 }
282
283 fn maybe_truncate_tool_result(
291 &self,
292 mut result: oxi_ai::ToolResultMessage,
293 ) -> oxi_ai::ToolResultMessage {
294 let Some(max_bytes) = self.config.max_tool_result_bytes else {
295 return result;
296 };
297
298 for block in &mut result.content {
299 if let oxi_ai::ContentBlock::Text(tc) = block
300 && tc.text.len() > max_bytes
301 {
302 let omitted = tc.text.len() - max_bytes;
303 tc.text.truncate(max_bytes);
304 tc.text.push_str(&format!(
305 "\n\n... [truncated: {omitted} bytes omitted, \
306 use read/grep for full content]"
307 ));
308 }
309 }
310
311 result
312 }
313
314 fn drain_follow_up_queue(&self) -> Vec<Message> {
315 drain_follow_up_queue(self)
316 }
317
318 pub fn cancel_auto_retry(&self) {
322 cancel_auto_retry(self);
323 }
324
325 pub fn auto_retry_attempt(&self) -> usize {
328 auto_retry_attempt_method(self)
329 }
330
331 pub fn state(&self) -> &SharedState {
334 &self.state
335 }
336
337 pub fn external_stop(&self) -> &Arc<AtomicBool> {
339 &self.external_stop
340 }
341
342 pub fn set_cancel_signal(&mut self, flag: Arc<AtomicBool>) {
347 self.cancel_signal = Some(flag);
348 }
349 pub fn set_auto_retry_state(
355 &mut self,
356 enabled: Arc<AtomicBool>,
357 cancel: Arc<AtomicBool>,
358 notify: Arc<tokio::sync::Notify>,
359 ) {
360 cancel.store(false, Ordering::SeqCst);
361 self.auto_retry_enabled_override = Some(enabled);
362 self.auto_retry_cancel_signal = Some(cancel);
363 self.auto_retry_notify_signal = Some(notify);
364 }
365
366 pub(crate) fn auto_retry_enabled(&self) -> bool {
369 self.auto_retry_enabled_override
370 .as_ref()
371 .map_or(self.config.auto_retry_enabled, |f| f.load(Ordering::SeqCst))
372 }
373
374 pub(crate) fn auto_retry_cancelled(&self) -> bool {
376 self.auto_retry_cancel.load(Ordering::SeqCst)
377 || self
378 .auto_retry_cancel_signal
379 .as_ref()
380 .is_some_and(|c| c.load(Ordering::SeqCst))
381 }
382
383 pub(crate) fn reset_auto_retry_cancel(&self) {
385 self.auto_retry_cancel.store(false, Ordering::SeqCst);
386 if let Some(c) = &self.auto_retry_cancel_signal {
387 c.store(false, Ordering::SeqCst);
388 }
389 }
390
391 pub(crate) fn fire_auto_retry_cancel(&self) {
394 self.auto_retry_cancel.store(true, Ordering::SeqCst);
395 if let Some(c) = &self.auto_retry_cancel_signal {
396 c.store(true, Ordering::SeqCst);
397 }
398 self.auto_retry_notify.notify_waiters();
399 if let Some(n) = &self.auto_retry_notify_signal {
400 n.notify_waiters();
401 }
402 }
403
404 pub(crate) async fn external_auto_retry_notified(&self) {
408 match &self.auto_retry_notify_signal {
409 Some(n) => n.notified().await,
410 None => std::future::pending::<()>().await,
411 }
412 }
413
414 pub fn cancel_signal(&self) -> Option<Arc<AtomicBool>> {
419 self.cancel_signal.as_ref().map(Arc::clone)
420 }
421
422 pub fn is_cancelled(&self) -> bool {
425 if self.external_stop.load(Ordering::SeqCst) {
426 return true;
427 }
428 self.cancel_signal
429 .as_ref()
430 .is_some_and(|f| f.load(Ordering::SeqCst))
431 }
432 pub fn cancel(&self) {
437 self.external_stop.store(true, Ordering::SeqCst);
438 }
439
440 pub fn set_steering_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
443 self.steering_hook = Some(hook);
444 }
445
446 pub fn set_follow_up_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
449 self.follow_up_hook = Some(hook);
450 }
451
452 fn poll_external_queues(&self) {
455 if let Some(ref hook) = self.steering_hook {
456 for msg in hook() {
457 self.steer(msg);
458 }
459 }
460 if let Some(ref hook) = self.follow_up_hook {
461 for msg in hook() {
462 self.follow_up(msg);
463 }
464 }
465 }
466
467 pub async fn run(
470 &self,
471 prompt: String,
472 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
473 ) -> Result<Vec<AgentEvent>> {
474 let message = Message::User(UserMessage::new(prompt));
475 let emit = Arc::new(emit);
476 self.run_messages(vec![message], emit).await
477 }
478
479 pub async fn run_message(
483 &self,
484 message: Message,
485 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
486 ) -> Result<Vec<AgentEvent>> {
487 let emit = Arc::new(emit);
488 self.run_messages(vec![message], emit).await
489 }
490
491 pub async fn run_mut<S: Send + std::fmt::Debug + 'static>(
522 &self,
523 prompt: String,
524 state: S,
525 emit: impl FnMut(AgentEvent, &mut S) + Send + 'static,
526 ) -> Result<(Vec<AgentEvent>, S)> {
527 let emit_fnmut = Arc::new(parking_lot::Mutex::new(emit));
528 let state_arc = Arc::new(parking_lot::Mutex::new(state));
529
530 let state_for_closure = Arc::clone(&state_arc);
532
533 let emit_fn: EmitFn = Arc::new(move |event: AgentEvent| {
534 let mut cb = emit_fnmut.lock();
535 let mut s = state_for_closure.lock();
536 cb(event, &mut s);
537 });
538
539 let events = self.run_inner(prompt, emit_fn).await?;
540
541 let mutex = Arc::try_unwrap(state_arc)
545 .expect("run_mut: state Arc still has multiple owners after run");
546 Ok((events, mutex.into_inner()))
547 }
548
549 async fn run_inner(&self, prompt: String, emit: EmitFn) -> Result<Vec<AgentEvent>> {
551 let message = Message::User(UserMessage::new(prompt));
552 self.run_messages(vec![message], emit).await
553 }
554
555 pub async fn run_messages(
558 &self,
559 prompts: Vec<Message>,
560 emit: EmitFn,
561 ) -> Result<Vec<AgentEvent>> {
562 let mut all_events = Vec::new();
563
564 let state_messages = self.state.get_state().messages.clone();
565 let mut all_messages = state_messages;
566 all_messages.extend(prompts.clone());
567
568 tracing::info!(session_id = ?self.session_id, "AgentLoop starting");
569 emit(AgentEvent::AgentStart {
570 prompts: prompts.clone(),
571 session_id: self.session_id.clone(),
572 });
573 all_events.push(AgentEvent::AgentStart {
574 prompts: prompts.clone(),
575 session_id: self.session_id.clone(),
576 });
577
578 let (result_messages, events) = self.run_loop(prompts, emit.clone()).await?;
579
580 all_events.extend(events);
581
582 let stop_reason = result_messages.last().and_then(|m| {
583 if let Message::Assistant(a) = m {
584 Some(format!("{:?}", a.stop_reason))
585 } else {
586 None
587 }
588 });
589
590 tracing::info!(session_id = ?self.session_id, "AgentLoop run_messages complete");
591
592 self.state.update(|s| {
594 s.replace_messages(result_messages.clone());
595 });
596
597 emit(AgentEvent::AgentEnd {
598 messages: result_messages.clone(),
599 stop_reason: stop_reason.clone(),
600 session_id: self.session_id.clone(),
601 });
602 all_events.push(AgentEvent::AgentEnd {
603 messages: result_messages.clone(),
604 stop_reason,
605 session_id: self.session_id.clone(),
606 });
607
608 Ok(all_events)
609 }
610
611 pub async fn continue_loop(
614 &self,
615 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
616 ) -> Result<Vec<AgentEvent>> {
617 let emit = Arc::new(emit);
618 let mut all_events = Vec::new();
619
620 tracing::info!(session_id = ?self.session_id, "AgentLoop continuing");
621 emit(AgentEvent::AgentStart {
622 prompts: vec![],
623 session_id: self.session_id.clone(),
624 });
625 all_events.push(AgentEvent::AgentStart {
626 prompts: vec![],
627 session_id: self.session_id.clone(),
628 });
629
630 let (result_messages, events) = self.run_loop(vec![], emit.clone()).await?;
631
632 all_events.extend(events);
633
634 let stop_reason = result_messages.last().and_then(|m| {
635 if let Message::Assistant(a) = m {
636 Some(format!("{:?}", a.stop_reason))
637 } else {
638 None
639 }
640 });
641
642 tracing::info!(session_id = ?self.session_id, "AgentLoop continue_loop complete");
643 emit(AgentEvent::AgentEnd {
644 messages: result_messages.clone(),
645 stop_reason: stop_reason.clone(),
646 session_id: self.session_id.clone(),
647 });
648 all_events.push(AgentEvent::AgentEnd {
649 messages: result_messages.clone(),
650 stop_reason,
651 session_id: self.session_id.clone(),
652 });
653
654 Ok(all_events)
655 }
656
657 fn process_steering_messages(
659 &self,
660 pending_messages: &mut Vec<Message>,
661 messages: &mut Vec<Message>,
662 new_messages: &mut Vec<Message>,
663 events: &mut Vec<AgentEvent>,
664 emit: &EmitFn,
665 ) {
666 if pending_messages.is_empty() {
667 return;
668 }
669 for message in pending_messages.drain(..) {
670 emit(AgentEvent::SteeringMessage {
671 message: message.clone(),
672 });
673 emit(AgentEvent::MessageStart {
674 message: message.clone(),
675 });
676 emit(AgentEvent::MessageEnd {
677 message: message.clone(),
678 });
679 events.push(AgentEvent::SteeringMessage {
680 message: message.clone(),
681 });
682 events.push(AgentEvent::MessageStart {
683 message: message.clone(),
684 });
685 events.push(AgentEvent::MessageEnd {
686 message: message.clone(),
687 });
688 messages.push(message.clone());
689 new_messages.push(message);
690 }
691 }
692
693 async fn handle_streaming_error(
695 &self,
696 e: anyhow::Error,
697 messages: &mut Vec<Message>,
698 new_messages: &mut Vec<Message>,
699 events: &mut Vec<AgentEvent>,
700 emit: &EmitFn,
701 turn_number: u32,
702 ) -> (Vec<Message>, Vec<AgentEvent>) {
703 let err_msg = format!("{}", e);
704 tracing::error!(session_id = ?self.session_id, "Unexpected streaming error: {}", err_msg);
705
706 let mut error_asst = oxi_ai::AssistantMessage::new(
707 oxi_ai::Api::OpenAiCompletions,
708 "agent",
709 &self.config.model_id,
710 );
711 error_asst.stop_reason = StopReason::Error;
712 error_asst
713 .content
714 .push(ContentBlock::Text(TextContent::new(format!(
715 "⚠ {}",
716 err_msg
717 ))));
718
719 new_messages.push(Message::Assistant(error_asst.clone()));
720 messages.push(Message::Assistant(error_asst.clone()));
721
722 emit(AgentEvent::MessageStart {
723 message: Message::Assistant(error_asst.clone()),
724 });
725 emit(AgentEvent::MessageEnd {
726 message: Message::Assistant(error_asst.clone()),
727 });
728 emit(AgentEvent::Error {
729 message: err_msg.clone(),
730 session_id: self.session_id.clone(),
731 });
732
733 emit(AgentEvent::TurnEnd {
734 turn_number,
735 assistant_message: Message::Assistant(error_asst.clone()),
736 tool_results: vec![],
737 });
738 events.push(AgentEvent::TurnEnd {
739 turn_number,
740 assistant_message: Message::Assistant(error_asst),
741 tool_results: vec![],
742 });
743 (messages.clone(), events.clone())
745 }
746
747 async fn run_loop(
748 &self,
749 initial_prompts: Vec<Message>,
750 emit: EmitFn,
751 ) -> Result<(Vec<Message>, Vec<AgentEvent>)> {
752 tracing::info!("[AGENT-LOOP] run_loop started");
753 let mut messages = self.state.get_state().messages.clone();
754 messages.extend(initial_prompts.clone());
755
756 let mut new_messages: Vec<Message> = initial_prompts;
757 let mut events = Vec::new();
758 let mut turn_number: u32 = 0;
759 let mut first_turn = true;
760
761 let mut pending_messages: Vec<Message> = self.drain_steering_queue();
762
763 let mut append_only =
765 crate::agent_loop::append_only::AppendOnlyContext::new(messages.clone());
766
767 loop {
768 tracing::info!(
769 "[AGENT-LOOP] Top of loop, has_more_tool_calls={}, pending_messages={}",
770 true,
771 pending_messages.is_empty()
772 );
773 let mut has_more_tool_calls = true;
774
775 while has_more_tool_calls || !pending_messages.is_empty() {
776 if !first_turn {
777 turn_number += 1;
778 emit(AgentEvent::TurnStart { turn_number });
779 events.push(AgentEvent::TurnStart { turn_number });
780 } else {
781 first_turn = false;
782 turn_number = 1;
783 emit(AgentEvent::TurnStart { turn_number });
784 events.push(AgentEvent::TurnStart { turn_number });
785 }
786
787 if !pending_messages.is_empty() {
788 self.process_steering_messages(
789 &mut pending_messages,
790 &mut messages,
791 &mut new_messages,
792 &mut events,
793 &emit,
794 );
795 }
796
797 self.poll_external_queues();
800
801 self.maybe_compact(&mut messages, turn_number as usize, &emit)
802 .await;
803
804 append_only.sync_from(&messages);
807
808 tracing::info!("[AGENT-LOOP] About to call stream_assistant_response");
809 let ttsr = self.ttsr_engine.as_deref();
810 let outcome = stream_assistant_response(self, &mut messages, &emit, ttsr).await;
811
812 let assistant_message = match outcome {
813 StreamOutcome::Complete(msg) => msg,
814 StreamOutcome::Error {
815 message: _message,
816 detail,
817 } => {
818 let is_tool_ordering_error = detail.contains("tool")
821 && (detail.contains("must be a response")
822 || detail.contains("preceding")
823 || detail.contains("tool_calls"));
824
825 if is_tool_ordering_error {
826 let removed = sanitize_orphaned_tool_results(&mut messages);
827 tracing::warn!(
828 session_id = ?self.session_id,
829 removed,
830 detail = %detail,
831 "Message-ordering error detected, removed orphaned tool results, retrying"
832 );
833 if removed > 0 {
834 emit(AgentEvent::Error {
836 message: format!(
837 "⚠ Provider rejected message order: {}. Removed {} orphaned tool results, retrying…",
838 detail, removed
839 ),
840 session_id: self.session_id.clone(),
841 });
842 continue; }
844 }
845
846 return Ok(self
848 .handle_streaming_error(
849 anyhow::anyhow!("Provider stream error: {}", detail),
850 &mut messages,
851 &mut new_messages,
852 &mut events,
853 &emit,
854 turn_number,
855 )
856 .await);
857 }
858 StreamOutcome::Cancelled(msg) => {
859 emit(AgentEvent::TurnEnd {
860 turn_number,
861 assistant_message: Message::Assistant(msg.clone()),
862 tool_results: vec![],
863 });
864 return Ok((messages, events));
865 }
866 StreamOutcome::RuleInterrupt { partial, rule } => {
867 tracing::info!("RuleInterrupt: '{}' violated, retrying", rule.name);
868 emit(AgentEvent::TtsrInterrupt {
869 rule_name: rule.name.clone(),
870 session_id: self.session_id.clone(),
871 });
872 messages.push(Message::Assistant(partial));
873 let interrupt_body = TTSR_INTERRUPT_TEMPLATE
878 .replace("{name}", &rule.name)
879 .replace("{content}", &rule.content);
880 messages.push(Message::user(interrupt_body));
881 continue;
882 }
883 };
884
885 new_messages.push(Message::Assistant(assistant_message.clone()));
886
887 if matches!(assistant_message.stop_reason, StopReason::Error) {
888 if is_retryable_error(&assistant_message) {
889 let did_retry =
890 handle_retryable_error(self, &assistant_message, &mut messages, &emit)
891 .await;
892 if did_retry {
893 emit(AgentEvent::TurnEnd {
894 turn_number,
895 assistant_message: Message::Assistant(assistant_message.clone()),
896 tool_results: vec![],
897 });
898 events.push(AgentEvent::TurnEnd {
899 turn_number,
900 assistant_message: Message::Assistant(assistant_message.clone()),
901 tool_results: vec![],
902 });
903 has_more_tool_calls = true;
904 continue;
905 }
906 }
907
908 emit(AgentEvent::TurnEnd {
909 turn_number,
910 assistant_message: Message::Assistant(assistant_message.clone()),
911 tool_results: vec![],
912 });
913 events.push(AgentEvent::TurnEnd {
914 turn_number,
915 assistant_message: Message::Assistant(assistant_message.clone()),
916 tool_results: vec![],
917 });
918 return Ok((messages, events));
919 }
920 if matches!(assistant_message.stop_reason, StopReason::Aborted) {
921 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
922 emit(AgentEvent::AutoRetryEnd {
923 success: true,
924 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
925 final_error: None,
926 });
927 self.auto_retry_attempt.store(0, Ordering::Relaxed);
928 }
929
930 emit(AgentEvent::TurnEnd {
931 turn_number,
932 assistant_message: Message::Assistant(assistant_message.clone()),
933 tool_results: vec![],
934 });
935 events.push(AgentEvent::TurnEnd {
936 turn_number,
937 assistant_message: Message::Assistant(assistant_message.clone()),
938 tool_results: vec![],
939 });
940 return Ok((messages, events));
941 }
942
943 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
944 emit(AgentEvent::AutoRetryEnd {
945 success: true,
946 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
947 final_error: None,
948 });
949 self.auto_retry_attempt.store(0, Ordering::Relaxed);
950 }
951
952 let tool_calls = helpers::extract_tool_calls(&assistant_message);
953 tracing::info!(
954 "[AGENT-LOOP] extract_tool_calls found {} calls, stop_reason={:?}",
955 tool_calls.len(),
956 assistant_message.stop_reason
957 );
958
959 let mut tool_results: Vec<oxi_ai::ToolResultMessage> = Vec::new();
960 has_more_tool_calls = false;
961
962 if !tool_calls.is_empty() {
963 tracing::info!("[AGENT-LOOP] Executing {} tool calls", tool_calls.len());
964 let ctx = self.build_tool_context();
965 let executed_batch = match execute_tool_calls(
966 self,
967 &mut messages,
968 &assistant_message,
969 tool_calls,
970 &emit,
971 &ctx,
972 )
973 .await
974 {
975 Ok(batch) => batch,
976 Err(e) => {
977 tracing::error!(session_id = ?self.session_id, "Tool execution error: {}", e);
980 emit(AgentEvent::Error {
981 message: format!("Tool execution error: {}", e),
982 session_id: self.session_id.clone(),
983 });
984 emit(AgentEvent::TurnEnd {
985 turn_number,
986 assistant_message: Message::Assistant(assistant_message.clone()),
987 tool_results: vec![],
988 });
989 events.push(AgentEvent::TurnEnd {
990 turn_number,
991 assistant_message: Message::Assistant(assistant_message.clone()),
992 tool_results: vec![],
993 });
994 return Ok((messages, events));
995 }
996 };
997
998 tool_results = executed_batch.messages;
999 has_more_tool_calls = !executed_batch.terminate;
1000
1001 if executed_batch.terminate {
1002 tracing::warn!(
1003 session_id = ?self.session_id,
1004 "Tool batch terminated early (terminate flag set by after_tool_call hook). \
1005 This halts the tool-calling loop. If this is unexpected, \
1006 check after_tool_call hooks for unintended terminate: true."
1007 );
1008 }
1009
1010 for result in &tool_results {
1011 let result = self.maybe_truncate_tool_result(result.clone());
1012 messages.push(Message::ToolResult(result.clone()));
1013 new_messages.push(Message::ToolResult(result));
1014 }
1015 if has_more_tool_calls {
1017 use oxi_ai::utils::tool_call_loop::{
1018 ToolCallLoopTurn, ToolCallRef, ToolResultRef,
1019 };
1020 let call_refs: Vec<ToolCallRef> = assistant_message
1021 .content
1022 .iter()
1023 .filter_map(|block| match block {
1024 oxi_ai::ContentBlock::ToolCall(tc) => Some(ToolCallRef {
1025 id: tc.id.clone(),
1026 name: tc.name.clone(),
1027 arguments: tc.arguments.clone(),
1028 }),
1029 _ => None,
1030 })
1031 .collect();
1032 let result_refs: Vec<ToolResultRef> = tool_results
1033 .iter()
1034 .map(|tr| {
1035 let text: String = tr
1036 .content
1037 .iter()
1038 .filter_map(|b| match b {
1039 oxi_ai::ContentBlock::Text(t) => Some(t.text.as_str()),
1040 _ => None,
1041 })
1042 .collect::<Vec<_>>()
1043 .join("\n");
1044 ToolResultRef {
1045 tool_call_id: tr.tool_call_id.clone(),
1046 content: text,
1047 }
1048 })
1049 .collect();
1050 let turn = ToolCallLoopTurn {
1051 tool_calls: &call_refs,
1052 tool_results: &result_refs,
1053 };
1054 let detection = self.tool_call_loop_guard.lock().record_turn(turn);
1060 if let Some(detection) = detection {
1061 let steering = format!(
1062 "Tool-call loop detected: '{}' called {} consecutive \
1063 times with identical arguments. Result: '{}'. \
1064 Try a different approach.",
1065 detection.tool_name, detection.count, detection.result_summary,
1066 );
1067 let msg = Message::User(oxi_ai::UserMessage::new(steering));
1068 messages.push(msg.clone());
1069 new_messages.push(msg);
1070 tracing::warn!(
1071 session_id = ?self.session_id,
1072 tool = %detection.tool_name,
1073 count = detection.count,
1074 "tool-call loop detected; injecting steering message"
1075 );
1076 self.tool_call_loop_guard.lock().reset();
1077 }
1078 }
1079 }
1080
1081 let assistant_has_tool_calls = assistant_message
1085 .content
1086 .iter()
1087 .any(|b| matches!(b, oxi_ai::ContentBlock::ToolCall(_)));
1088 if !self.config.soft_requirements.is_empty() && assistant_has_tool_calls {
1089 let called_tools: std::collections::HashSet<String> = assistant_message
1090 .content
1091 .iter()
1092 .filter_map(|block| match block {
1093 oxi_ai::ContentBlock::ToolCall(tc) => Some(tc.name.clone()),
1094 _ => None,
1095 })
1096 .collect();
1097
1098 for req in &self.config.soft_requirements {
1099 if called_tools.contains(&req.tool_name) {
1100 self.soft_requirement_state
1101 .lock()
1102 .reminded
1103 .remove(&req.tool_name);
1104 continue;
1105 }
1106
1107 if self
1108 .soft_requirement_state
1109 .lock()
1110 .reminded
1111 .contains(&req.tool_name)
1112 {
1113 tracing::warn!(
1114 session_id = ?self.session_id,
1115 tool = %req.tool_name,
1116 "Soft requirement escalation"
1117 );
1118 emit(AgentEvent::SoftRequirementEscalation {
1119 tool_name: req.tool_name.clone(),
1120 reason: req.reason.clone(),
1121 session_id: self.session_id.clone(),
1122 });
1123 let escalate_msg = Message::User(oxi_ai::UserMessage::new(format!(
1124 "[IMPORTANT] You still have not used the `{}` tool, which is required. {}",
1125 req.tool_name, req.reason,
1126 )));
1127 messages.push(escalate_msg.clone());
1128 new_messages.push(escalate_msg);
1129 } else {
1130 tracing::info!(
1131 session_id = ?self.session_id,
1132 tool = %req.tool_name,
1133 "Soft requirement reminder"
1134 );
1135 self.soft_requirement_state
1136 .lock()
1137 .reminded
1138 .insert(req.tool_name.clone());
1139 emit(AgentEvent::SoftRequirementReminder {
1140 tool_name: req.tool_name.clone(),
1141 reason: req.reason.clone(),
1142 session_id: self.session_id.clone(),
1143 });
1144 let reminder_msg = Message::User(oxi_ai::UserMessage::new(format!(
1145 "Reminder: please use the `{}` tool. {}",
1146 req.tool_name, req.reason,
1147 )));
1148 messages.push(reminder_msg.clone());
1149 new_messages.push(reminder_msg);
1150 }
1151 }
1152 }
1153
1154 emit(AgentEvent::TurnEnd {
1155 turn_number,
1156 assistant_message: Message::Assistant(assistant_message.clone()),
1157 tool_results: tool_results.clone(),
1158 });
1159 events.push(AgentEvent::TurnEnd {
1160 turn_number,
1161 assistant_message: Message::Assistant(assistant_message.clone()),
1162 tool_results: tool_results.clone(),
1163 });
1164
1165 if should_stop_after_turn(&self.external_stop) {
1166 tracing::info!("[AGENT-LOOP] external_stop, ending loop");
1167 return Ok((messages, events));
1168 }
1169
1170 pending_messages = self.drain_steering_queue();
1171 tracing::info!(
1172 "[AGENT-LOOP] TurnEnd complete, pending_messages={}, has_more_tool_calls={}",
1173 !pending_messages.is_empty(),
1174 has_more_tool_calls
1175 );
1176
1177 if self.external_stop.load(Ordering::SeqCst) {
1180 tracing::info!(
1181 "[AGENT-LOOP] external_stop set after steering drain, ending loop"
1182 );
1183 return Ok((messages, events));
1184 }
1185 }
1186
1187 let late_steering = self.drain_steering_queue();
1191 if !late_steering.is_empty() {
1192 tracing::info!(
1193 count = late_steering.len(),
1194 "[AGENT-LOOP] Caught late steering messages after inner loop exit"
1195 );
1196 pending_messages = late_steering;
1197 continue;
1198 }
1199
1200 let follow_up_messages = self.drain_follow_up_queue();
1201 if !follow_up_messages.is_empty() {
1202 pending_messages = follow_up_messages;
1203 continue;
1204 }
1205
1206 let final_steering = self.drain_steering_queue();
1209 if !final_steering.is_empty() {
1210 pending_messages = final_steering;
1211 continue;
1212 }
1213
1214 break;
1215 }
1216
1217 append_only.sync_from(&messages);
1219
1220 Ok((messages, events))
1221 }
1222
1223 fn build_compaction_instruction(&self) -> Option<String> {
1226 let base = self.config.compaction_instruction.as_deref();
1227 let injected = self
1228 .ttsr_engine
1229 .as_ref()
1230 .map(|e| e.injected_records())
1231 .unwrap_or_default();
1232 if injected.is_empty() {
1233 return base.map(|s| s.to_string());
1234 }
1235 let mut instr = base.map(|s| s.to_string()).unwrap_or_default();
1236 instr.push_str("\n\nThe following rules have already been enforced in this session and corrections applied. Do NOT violate them again:");
1237 for (name, _turn) in &injected {
1238 instr.push_str(&format!("\n- {name}"));
1239 }
1240 Some(instr)
1241 }
1242
1243 async fn maybe_compact(&self, messages: &mut Vec<Message>, iteration: usize, emit: &EmitFn) {
1244 let snapshot = self.state.get_state();
1260 let (context_tokens, source_label) = match snapshot.current_token_source() {
1261 TokenSource::Real(n) => (n, "provider-reported"),
1262 TokenSource::Heuristic(n) => (n, "bytes/4 heuristic (cold start)"),
1263 TokenSource::None => (0, "empty"),
1264 };
1265 if let Some(div) = snapshot.last_estimate_divergence
1270 && div > 2.0
1271 {
1272 tracing::warn!(
1273 session_id = ?self.session_id,
1274 divergence = div,
1275 reported = snapshot.last_input_tokens.unwrap_or(0),
1276 estimate = snapshot.last_estimate_at_report.unwrap_or(0),
1277 "Token-count heuristic (bytes/4) diverges from provider-reported usage \
1278 by >2x; CompactionStrategy::Threshold decisions are using the \
1279 provider-reported count (issue #28 gap 2)."
1280 );
1281 }
1282 drop(snapshot);
1283
1284 if !self
1285 .compaction_manager
1286 .should_compact(context_tokens, iteration)
1287 {
1288 return;
1289 }
1290
1291 emit(AgentEvent::Compaction {
1292 event: CompactionEvent::Triggered {
1293 context_tokens,
1294 iteration,
1295 source: source_label.to_string(),
1296 },
1297 });
1298
1299 let messages_to_compact: Vec<Message> = messages.to_vec();
1300 let instruction = self.build_compaction_instruction();
1301
1302 match self
1303 .compaction_manager
1304 .compact_if_needed(
1305 &messages_to_compact,
1306 instruction.as_deref(),
1307 context_tokens,
1308 iteration,
1309 )
1310 .await
1311 {
1312 Ok(Some(compacted)) => {
1313 let start = Instant::now();
1314 let message_count = compacted.compacted_count;
1315
1316 emit(AgentEvent::Compaction {
1317 event: CompactionEvent::Started { message_count },
1318 });
1319
1320 let kept_messages = compacted.kept_messages;
1321 let summary = compacted.summary;
1322 let compacted_count = compacted.compacted_count;
1323
1324 *messages = kept_messages;
1325
1326 let state_msgs = messages.clone();
1327 self.state.update(|s| {
1328 s.replace_messages(state_msgs);
1329 });
1330
1331 let compacted_ctx = CompactedContext {
1332 summary,
1333 kept_messages: Vec::new(),
1334 compacted_count,
1335 };
1336 emit(AgentEvent::Compaction {
1337 event: CompactionEvent::Completed {
1338 result: compacted_ctx.clone(),
1339 duration_ms: start.elapsed().as_millis() as u64,
1340 },
1341 });
1342
1343 if let Some(ref hook) = self.config.on_compaction {
1345 match hook(compacted_ctx).await {
1346 Ok(()) => {
1347 tracing::debug!("Compaction hook completed successfully");
1348 }
1349 Err(e) => {
1350 tracing::warn!(error = %e, "Compaction hook failed");
1351 }
1352 }
1353 }
1354 }
1355 Ok(None) => {}
1356 Err(e) => {
1357 emit(AgentEvent::Compaction {
1358 event: CompactionEvent::Failed {
1359 error: e.to_string(),
1360 },
1361 });
1362 }
1363 }
1364 }
1365
1366 fn resolve_model(&self) -> Result<oxi_ai::Model> {
1367 self.resolver
1368 .resolve_model(&self.config.model_id)
1369 .ok_or_else(|| Error::msg(format!("Model not found: {}", self.config.model_id)))
1370 }
1371}
1372
1373#[cfg(test)]
1374mod session_id_wiring_tests {
1375 use super::*;
1380 use crate::ProviderResolver;
1381 use crate::agent_loop::config::AgentLoopConfig;
1382 use crate::config::ToolExecutionMode;
1383 use crate::state::SharedState;
1384 use crate::tools::ToolRegistry;
1385 use oxi_ai::{
1386 CompactionStrategy, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1387 };
1388 use std::future::Future;
1389 use std::pin::Pin;
1390
1391 struct NopProvider;
1392 impl Provider for NopProvider {
1393 fn stream<'a>(
1394 &'a self,
1395 _model: &'a Model,
1396 _context: &'a Context,
1397 _options: Option<StreamOptions>,
1398 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1399 Box::pin(async {
1400 Err(ProviderError::NotImplemented(
1401 "session-id wiring tests never stream".to_string(),
1402 ))
1403 })
1404 }
1405 }
1406
1407 struct NullResolver;
1408 impl ProviderResolver for NullResolver {
1409 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1410 None
1411 }
1412 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1413 None
1414 }
1415 }
1416
1417 fn loop_with(session_id: Option<String>) -> AgentLoop {
1418 let config = AgentLoopConfig {
1419 model_id: "test/model".to_string(),
1420 system_prompt: None,
1421 temperature: 1.0,
1422 max_tokens: 4096,
1423 tool_execution: ToolExecutionMode::Sequential,
1424 compaction_strategy: CompactionStrategy::Disabled,
1425 compaction_instruction: None,
1426 context_window: 128_000,
1427 session_id,
1428 transport: None,
1429 compact_on_start: false,
1430 max_retry_delay_ms: None,
1431 auto_retry_enabled: true,
1432 auto_retry_max_attempts: 3,
1433 auto_retry_base_delay_ms: 1000,
1434 workspace_dir: None,
1435 provider_options: None,
1436 on_compaction: None,
1437 snapshot_store: None,
1438 memory: None,
1439 url_resolver: None,
1440 todo: None,
1441 agent_pool: None,
1442 lsp: None,
1443 ttsr_engine: None,
1444 subagent_runner: None,
1445 subagent_depth: 0,
1446 max_tool_result_bytes: None,
1447 thinking_loop_detection: false, ..Default::default()
1449 };
1450 AgentLoop::new_with_resolver(
1451 Arc::new(NopProvider),
1452 config,
1453 Arc::new(ToolRegistry::new()),
1454 SharedState::new(),
1455 Arc::new(NullResolver),
1456 )
1457 }
1458
1459 #[test]
1465 fn tool_context_inherits_session_id_when_set() {
1466 let loop_ = loop_with(Some("proc-test-session-id".to_string()));
1467 let ctx = loop_.build_tool_context();
1468 assert_eq!(
1469 ctx.session_id.as_deref(),
1470 Some("proc-test-session-id"),
1471 "ToolContext.session_id must inherit AgentConfig.session_id"
1472 );
1473 }
1474
1475 #[test]
1476 fn tool_context_session_id_defaults_to_none() {
1477 let loop_ = loop_with(None);
1478 let ctx = loop_.build_tool_context();
1479 assert!(
1480 ctx.session_id.is_none(),
1481 "default ToolContext.session_id should be None"
1482 );
1483 }
1484}
1485
1486#[cfg(test)]
1489mod truncation_tests {
1490 use super::*;
1491 use crate::agent::ProviderResolver;
1492 use oxi_ai::{
1493 ContentBlock, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1494 TextContent, ToolResultMessage,
1495 };
1496 use std::future::Future;
1497 use std::pin::Pin;
1498
1499 struct NopProvider;
1500 impl Provider for NopProvider {
1501 fn stream<'a>(
1502 &'a self,
1503 _model: &'a Model,
1504 _context: &'a Context,
1505 _options: Option<StreamOptions>,
1506 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1507 Box::pin(async {
1508 Err(ProviderError::NotImplemented(
1509 "truncation tests never stream".to_string(),
1510 ))
1511 })
1512 }
1513 }
1514
1515 struct NullResolver;
1516 impl ProviderResolver for NullResolver {
1517 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1518 None
1519 }
1520 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1521 None
1522 }
1523 }
1524
1525 fn make_result(text: &str) -> ToolResultMessage {
1526 ToolResultMessage::new(
1527 "tc_test".to_string(),
1528 "test_tool",
1529 vec![ContentBlock::Text(TextContent::new(text.to_string()))],
1530 )
1531 }
1532
1533 fn loop_with_limit(limit: Option<usize>) -> AgentLoop {
1534 let config = AgentLoopConfig {
1535 model_id: "test/model".to_string(),
1536 max_tool_result_bytes: limit,
1537 ..Default::default()
1538 };
1539 AgentLoop::new_with_resolver(
1540 Arc::new(NopProvider),
1541 config,
1542 Arc::new(ToolRegistry::new()),
1543 SharedState::new(),
1544 Arc::new(NullResolver),
1545 )
1546 }
1547
1548 #[test]
1549 fn truncate_passthrough_when_none() {
1550 let loop_ = loop_with_limit(None);
1551 let result = make_result(&"x".repeat(10_000));
1552 let truncated = loop_.maybe_truncate_tool_result(result);
1553 if let ContentBlock::Text(tc) = &truncated.content[0] {
1554 assert_eq!(tc.text.len(), 10_000);
1555 assert!(!tc.text.contains("truncated"));
1556 }
1557 }
1558
1559 #[test]
1560 fn truncate_passthrough_when_under_limit() {
1561 let loop_ = loop_with_limit(Some(1000));
1562 let result = make_result(&"x".repeat(500));
1563 let truncated = loop_.maybe_truncate_tool_result(result);
1564 if let ContentBlock::Text(tc) = &truncated.content[0] {
1565 assert_eq!(tc.text.len(), 500);
1566 assert!(!tc.text.contains("truncated"));
1567 }
1568 }
1569
1570 #[test]
1571 fn truncate_applies_when_over_limit() {
1572 let loop_ = loop_with_limit(Some(100));
1573 let result = make_result(&"x".repeat(500));
1574 let truncated = loop_.maybe_truncate_tool_result(result);
1575 if let ContentBlock::Text(tc) = &truncated.content[0] {
1576 assert!(
1577 tc.text.len() < 500,
1578 "text not truncated: {} bytes",
1579 tc.text.len()
1580 );
1581 assert!(tc.text.contains("truncated"), "missing truncation marker");
1582 assert!(tc.text.contains("400 bytes omitted"));
1583 }
1584 }
1585}