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