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