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 oxicode_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<oxicode_ai::utils::thinking_loop::ThinkingLoopDetector>>,
113 tool_call_loop_guard: parking_lot::Mutex<oxicode_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(oxicode_ai::utils::thinking_loop::ThinkingLoopDetector::new())
170 } else {
171 None
172 }),
173 tool_call_loop_guard: parking_lot::Mutex::new(
174 oxicode_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: oxicode_ai::ToolResultMessage,
293 ) -> oxicode_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 oxicode_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 #[allow(clippy::expect_used)]
547 let mutex = Arc::try_unwrap(state_arc)
548 .expect("run_mut: state Arc still has multiple owners after run");
549 Ok((events, mutex.into_inner()))
550 }
551
552 async fn run_inner(&self, prompt: String, emit: EmitFn) -> Result<Vec<AgentEvent>> {
554 let message = Message::User(UserMessage::new(prompt));
555 self.run_messages(vec![message], emit).await
556 }
557
558 pub async fn run_messages(
561 &self,
562 prompts: Vec<Message>,
563 emit: EmitFn,
564 ) -> Result<Vec<AgentEvent>> {
565 let mut all_events = Vec::new();
566
567 let state_messages = self.state.get_state().messages.clone();
568 let mut all_messages = state_messages;
569 all_messages.extend(prompts.clone());
570
571 tracing::info!(session_id = ?self.session_id, "AgentLoop starting");
572 emit(AgentEvent::AgentStart {
573 prompts: prompts.clone(),
574 session_id: self.session_id.clone(),
575 });
576 all_events.push(AgentEvent::AgentStart {
577 prompts: prompts.clone(),
578 session_id: self.session_id.clone(),
579 });
580
581 let (result_messages, events) = self.run_loop(prompts, emit.clone()).await?;
582
583 all_events.extend(events);
584
585 let stop_reason = result_messages.last().and_then(|m| {
586 if let Message::Assistant(a) = m {
587 Some(format!("{:?}", a.stop_reason))
588 } else {
589 None
590 }
591 });
592
593 tracing::info!(session_id = ?self.session_id, "AgentLoop run_messages complete");
594
595 self.state.update(|s| {
597 s.replace_messages(result_messages.clone());
598 });
599
600 emit(AgentEvent::AgentEnd {
601 messages: result_messages.clone(),
602 stop_reason: stop_reason.clone(),
603 session_id: self.session_id.clone(),
604 });
605 all_events.push(AgentEvent::AgentEnd {
606 messages: result_messages.clone(),
607 stop_reason,
608 session_id: self.session_id.clone(),
609 });
610
611 Ok(all_events)
612 }
613
614 pub async fn continue_loop(
617 &self,
618 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
619 ) -> Result<Vec<AgentEvent>> {
620 let emit = Arc::new(emit);
621 let mut all_events = Vec::new();
622
623 tracing::info!(session_id = ?self.session_id, "AgentLoop continuing");
624 emit(AgentEvent::AgentStart {
625 prompts: vec![],
626 session_id: self.session_id.clone(),
627 });
628 all_events.push(AgentEvent::AgentStart {
629 prompts: vec![],
630 session_id: self.session_id.clone(),
631 });
632
633 let (result_messages, events) = self.run_loop(vec![], emit.clone()).await?;
634
635 all_events.extend(events);
636
637 let stop_reason = result_messages.last().and_then(|m| {
638 if let Message::Assistant(a) = m {
639 Some(format!("{:?}", a.stop_reason))
640 } else {
641 None
642 }
643 });
644
645 tracing::info!(session_id = ?self.session_id, "AgentLoop continue_loop complete");
646 emit(AgentEvent::AgentEnd {
647 messages: result_messages.clone(),
648 stop_reason: stop_reason.clone(),
649 session_id: self.session_id.clone(),
650 });
651 all_events.push(AgentEvent::AgentEnd {
652 messages: result_messages.clone(),
653 stop_reason,
654 session_id: self.session_id.clone(),
655 });
656
657 Ok(all_events)
658 }
659
660 fn process_steering_messages(
662 &self,
663 pending_messages: &mut Vec<Message>,
664 messages: &mut Vec<Message>,
665 new_messages: &mut Vec<Message>,
666 events: &mut Vec<AgentEvent>,
667 emit: &EmitFn,
668 ) {
669 if pending_messages.is_empty() {
670 return;
671 }
672 for message in pending_messages.drain(..) {
673 emit(AgentEvent::SteeringMessage {
674 message: message.clone(),
675 });
676 emit(AgentEvent::MessageStart {
677 message: message.clone(),
678 });
679 emit(AgentEvent::MessageEnd {
680 message: message.clone(),
681 });
682 events.push(AgentEvent::SteeringMessage {
683 message: message.clone(),
684 });
685 events.push(AgentEvent::MessageStart {
686 message: message.clone(),
687 });
688 events.push(AgentEvent::MessageEnd {
689 message: message.clone(),
690 });
691 messages.push(message.clone());
692 new_messages.push(message);
693 }
694 }
695
696 async fn handle_streaming_error(
698 &self,
699 e: anyhow::Error,
700 messages: &mut Vec<Message>,
701 new_messages: &mut Vec<Message>,
702 events: &mut Vec<AgentEvent>,
703 emit: &EmitFn,
704 turn_number: u32,
705 ) -> (Vec<Message>, Vec<AgentEvent>) {
706 let err_msg = format!("{}", e);
707 tracing::error!(session_id = ?self.session_id, "Unexpected streaming error: {}", err_msg);
708
709 let mut error_asst = oxicode_ai::AssistantMessage::new(
710 oxicode_ai::Api::OpenAiCompletions,
711 "agent",
712 &self.config.model_id,
713 );
714 error_asst.stop_reason = StopReason::Error;
715 error_asst
716 .content
717 .push(ContentBlock::Text(TextContent::new(format!(
718 "⚠ {}",
719 err_msg
720 ))));
721
722 new_messages.push(Message::Assistant(error_asst.clone()));
723 messages.push(Message::Assistant(error_asst.clone()));
724
725 emit(AgentEvent::MessageStart {
726 message: Message::Assistant(error_asst.clone()),
727 });
728 emit(AgentEvent::MessageEnd {
729 message: Message::Assistant(error_asst.clone()),
730 });
731 emit(AgentEvent::Error {
732 message: err_msg.clone(),
733 session_id: self.session_id.clone(),
734 });
735
736 emit(AgentEvent::TurnEnd {
737 turn_number,
738 assistant_message: Message::Assistant(error_asst.clone()),
739 tool_results: vec![],
740 });
741 events.push(AgentEvent::TurnEnd {
742 turn_number,
743 assistant_message: Message::Assistant(error_asst),
744 tool_results: vec![],
745 });
746 (messages.clone(), events.clone())
748 }
749
750 async fn run_loop(
751 &self,
752 initial_prompts: Vec<Message>,
753 emit: EmitFn,
754 ) -> Result<(Vec<Message>, Vec<AgentEvent>)> {
755 tracing::info!("[AGENT-LOOP] run_loop started");
756 let mut messages = self.state.get_state().messages.clone();
757 messages.extend(initial_prompts.clone());
758
759 let mut new_messages: Vec<Message> = initial_prompts;
760 let mut events = Vec::new();
761 let mut turn_number: u32 = 0;
762 let mut first_turn = true;
763
764 let mut pending_messages: Vec<Message> = self.drain_steering_queue();
765
766 let mut append_only =
768 crate::agent_loop::append_only::AppendOnlyContext::new(messages.clone());
769
770 loop {
771 tracing::info!(
772 "[AGENT-LOOP] Top of loop, has_more_tool_calls={}, pending_messages={}",
773 true,
774 pending_messages.is_empty()
775 );
776 let mut has_more_tool_calls = true;
777
778 while has_more_tool_calls || !pending_messages.is_empty() {
779 if !first_turn {
780 turn_number += 1;
781 emit(AgentEvent::TurnStart { turn_number });
782 events.push(AgentEvent::TurnStart { turn_number });
783 } else {
784 first_turn = false;
785 turn_number = 1;
786 emit(AgentEvent::TurnStart { turn_number });
787 events.push(AgentEvent::TurnStart { turn_number });
788 }
789
790 if !pending_messages.is_empty() {
791 self.process_steering_messages(
792 &mut pending_messages,
793 &mut messages,
794 &mut new_messages,
795 &mut events,
796 &emit,
797 );
798 }
799
800 self.poll_external_queues();
803
804 self.maybe_compact(&mut messages, turn_number as usize, &emit)
805 .await;
806
807 append_only.sync_from(&messages);
810
811 tracing::info!("[AGENT-LOOP] About to call stream_assistant_response");
812 let ttsr = self.ttsr_engine.as_deref();
813 let outcome = stream_assistant_response(self, &mut messages, &emit, ttsr).await;
814
815 let assistant_message = match outcome {
816 StreamOutcome::Complete(msg) => msg,
817 StreamOutcome::Error {
818 message: _message,
819 detail,
820 } => {
821 let is_tool_ordering_error = detail.contains("tool")
824 && (detail.contains("must be a response")
825 || detail.contains("preceding")
826 || detail.contains("tool_calls"));
827
828 if is_tool_ordering_error {
829 let removed = sanitize_orphaned_tool_results(&mut messages);
830 tracing::warn!(
831 session_id = ?self.session_id,
832 removed,
833 detail = %detail,
834 "Message-ordering error detected, removed orphaned tool results, retrying"
835 );
836 if removed > 0 {
837 emit(AgentEvent::Error {
839 message: format!(
840 "⚠ Provider rejected message order: {}. Removed {} orphaned tool results, retrying…",
841 detail, removed
842 ),
843 session_id: self.session_id.clone(),
844 });
845 continue; }
847 }
848
849 return Ok(self
851 .handle_streaming_error(
852 anyhow::anyhow!("Provider stream error: {}", detail),
853 &mut messages,
854 &mut new_messages,
855 &mut events,
856 &emit,
857 turn_number,
858 )
859 .await);
860 }
861 StreamOutcome::Cancelled(msg) => {
862 emit(AgentEvent::TurnEnd {
863 turn_number,
864 assistant_message: Message::Assistant(msg.clone()),
865 tool_results: vec![],
866 });
867 return Ok((messages, events));
868 }
869 StreamOutcome::RuleInterrupt { partial, rule } => {
870 tracing::info!("RuleInterrupt: '{}' violated, retrying", rule.name);
871 emit(AgentEvent::TtsrInterrupt {
872 rule_name: rule.name.clone(),
873 session_id: self.session_id.clone(),
874 });
875 messages.push(Message::Assistant(partial));
876 let interrupt_body = TTSR_INTERRUPT_TEMPLATE
881 .replace("{name}", &rule.name)
882 .replace("{content}", &rule.content);
883 messages.push(Message::user(interrupt_body));
884 continue;
885 }
886 };
887
888 new_messages.push(Message::Assistant(assistant_message.clone()));
889
890 if matches!(assistant_message.stop_reason, StopReason::Error) {
891 if is_retryable_error(&assistant_message) {
892 let did_retry =
893 handle_retryable_error(self, &assistant_message, &mut messages, &emit)
894 .await;
895 if did_retry {
896 emit(AgentEvent::TurnEnd {
897 turn_number,
898 assistant_message: Message::Assistant(assistant_message.clone()),
899 tool_results: vec![],
900 });
901 events.push(AgentEvent::TurnEnd {
902 turn_number,
903 assistant_message: Message::Assistant(assistant_message.clone()),
904 tool_results: vec![],
905 });
906 has_more_tool_calls = true;
907 continue;
908 }
909 }
910
911 emit(AgentEvent::TurnEnd {
912 turn_number,
913 assistant_message: Message::Assistant(assistant_message.clone()),
914 tool_results: vec![],
915 });
916 events.push(AgentEvent::TurnEnd {
917 turn_number,
918 assistant_message: Message::Assistant(assistant_message.clone()),
919 tool_results: vec![],
920 });
921 return Ok((messages, events));
922 }
923 if matches!(assistant_message.stop_reason, StopReason::Aborted) {
924 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
925 emit(AgentEvent::AutoRetryEnd {
926 success: true,
927 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
928 final_error: None,
929 });
930 self.auto_retry_attempt.store(0, Ordering::Relaxed);
931 }
932
933 emit(AgentEvent::TurnEnd {
934 turn_number,
935 assistant_message: Message::Assistant(assistant_message.clone()),
936 tool_results: vec![],
937 });
938 events.push(AgentEvent::TurnEnd {
939 turn_number,
940 assistant_message: Message::Assistant(assistant_message.clone()),
941 tool_results: vec![],
942 });
943 return Ok((messages, events));
944 }
945
946 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
947 emit(AgentEvent::AutoRetryEnd {
948 success: true,
949 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
950 final_error: None,
951 });
952 self.auto_retry_attempt.store(0, Ordering::Relaxed);
953 }
954
955 let tool_calls = helpers::extract_tool_calls(&assistant_message);
956 tracing::info!(
957 "[AGENT-LOOP] extract_tool_calls found {} calls, stop_reason={:?}",
958 tool_calls.len(),
959 assistant_message.stop_reason
960 );
961
962 let mut tool_results: Vec<oxicode_ai::ToolResultMessage> = Vec::new();
963 has_more_tool_calls = false;
964
965 if !tool_calls.is_empty() {
966 tracing::info!("[AGENT-LOOP] Executing {} tool calls", tool_calls.len());
967 let ctx = self.build_tool_context();
968 let executed_batch = match execute_tool_calls(
969 self,
970 &mut messages,
971 &assistant_message,
972 tool_calls,
973 &emit,
974 &ctx,
975 )
976 .await
977 {
978 Ok(batch) => batch,
979 Err(e) => {
980 tracing::error!(session_id = ?self.session_id, "Tool execution error: {}", e);
983 emit(AgentEvent::Error {
984 message: format!("Tool execution error: {}", e),
985 session_id: self.session_id.clone(),
986 });
987 emit(AgentEvent::TurnEnd {
988 turn_number,
989 assistant_message: Message::Assistant(assistant_message.clone()),
990 tool_results: vec![],
991 });
992 events.push(AgentEvent::TurnEnd {
993 turn_number,
994 assistant_message: Message::Assistant(assistant_message.clone()),
995 tool_results: vec![],
996 });
997 return Ok((messages, events));
998 }
999 };
1000
1001 tool_results = executed_batch.messages;
1002 has_more_tool_calls = !executed_batch.terminate;
1003
1004 if executed_batch.terminate {
1005 tracing::warn!(
1006 session_id = ?self.session_id,
1007 "Tool batch terminated early (terminate flag set by after_tool_call hook). \
1008 This halts the tool-calling loop. If this is unexpected, \
1009 check after_tool_call hooks for unintended terminate: true."
1010 );
1011 }
1012
1013 for result in &tool_results {
1014 let result = self.maybe_truncate_tool_result(result.clone());
1015 messages.push(Message::ToolResult(result.clone()));
1016 new_messages.push(Message::ToolResult(result));
1017 }
1018 if has_more_tool_calls {
1020 use oxicode_ai::utils::tool_call_loop::{
1021 ToolCallLoopTurn, ToolCallRef, ToolResultRef,
1022 };
1023 let call_refs: Vec<ToolCallRef> = assistant_message
1024 .content
1025 .iter()
1026 .filter_map(|block| match block {
1027 oxicode_ai::ContentBlock::ToolCall(tc) => Some(ToolCallRef {
1028 id: tc.id.clone(),
1029 name: tc.name.clone(),
1030 arguments: tc.arguments.clone(),
1031 }),
1032 _ => None,
1033 })
1034 .collect();
1035 let result_refs: Vec<ToolResultRef> = tool_results
1036 .iter()
1037 .map(|tr| {
1038 let text: String = tr
1039 .content
1040 .iter()
1041 .filter_map(|b| match b {
1042 oxicode_ai::ContentBlock::Text(t) => Some(t.text.as_str()),
1043 _ => None,
1044 })
1045 .collect::<Vec<_>>()
1046 .join("\n");
1047 ToolResultRef {
1048 tool_call_id: tr.tool_call_id.clone(),
1049 content: text,
1050 }
1051 })
1052 .collect();
1053 let turn = ToolCallLoopTurn {
1054 tool_calls: &call_refs,
1055 tool_results: &result_refs,
1056 };
1057 let detection = self.tool_call_loop_guard.lock().record_turn(turn);
1063 if let Some(detection) = detection {
1064 let steering = format!(
1065 "Tool-call loop detected: '{}' called {} consecutive \
1066 times with identical arguments. Result: '{}'. \
1067 Try a different approach.",
1068 detection.tool_name, detection.count, detection.result_summary,
1069 );
1070 let msg = Message::User(oxicode_ai::UserMessage::new(steering));
1071 messages.push(msg.clone());
1072 new_messages.push(msg);
1073 tracing::warn!(
1074 session_id = ?self.session_id,
1075 tool = %detection.tool_name,
1076 count = detection.count,
1077 "tool-call loop detected; injecting steering message"
1078 );
1079 self.tool_call_loop_guard.lock().reset();
1080 }
1081 }
1082 }
1083
1084 let assistant_has_tool_calls = assistant_message
1088 .content
1089 .iter()
1090 .any(|b| matches!(b, oxicode_ai::ContentBlock::ToolCall(_)));
1091 if !self.config.soft_requirements.is_empty() && assistant_has_tool_calls {
1092 let called_tools: std::collections::HashSet<String> = assistant_message
1093 .content
1094 .iter()
1095 .filter_map(|block| match block {
1096 oxicode_ai::ContentBlock::ToolCall(tc) => Some(tc.name.clone()),
1097 _ => None,
1098 })
1099 .collect();
1100
1101 for req in &self.config.soft_requirements {
1102 if called_tools.contains(&req.tool_name) {
1103 self.soft_requirement_state
1104 .lock()
1105 .reminded
1106 .remove(&req.tool_name);
1107 continue;
1108 }
1109
1110 if self
1111 .soft_requirement_state
1112 .lock()
1113 .reminded
1114 .contains(&req.tool_name)
1115 {
1116 tracing::warn!(
1117 session_id = ?self.session_id,
1118 tool = %req.tool_name,
1119 "Soft requirement escalation"
1120 );
1121 emit(AgentEvent::SoftRequirementEscalation {
1122 tool_name: req.tool_name.clone(),
1123 reason: req.reason.clone(),
1124 session_id: self.session_id.clone(),
1125 });
1126 let escalate_msg = Message::User(oxicode_ai::UserMessage::new(
1127 format!(
1128 "[IMPORTANT] You still have not used the `{}` tool, which is required. {}",
1129 req.tool_name, req.reason,
1130 ),
1131 ));
1132 messages.push(escalate_msg.clone());
1133 new_messages.push(escalate_msg);
1134 } else {
1135 tracing::info!(
1136 session_id = ?self.session_id,
1137 tool = %req.tool_name,
1138 "Soft requirement reminder"
1139 );
1140 self.soft_requirement_state
1141 .lock()
1142 .reminded
1143 .insert(req.tool_name.clone());
1144 emit(AgentEvent::SoftRequirementReminder {
1145 tool_name: req.tool_name.clone(),
1146 reason: req.reason.clone(),
1147 session_id: self.session_id.clone(),
1148 });
1149 let reminder_msg =
1150 Message::User(oxicode_ai::UserMessage::new(format!(
1151 "Reminder: please use the `{}` tool. {}",
1152 req.tool_name, req.reason,
1153 )));
1154 messages.push(reminder_msg.clone());
1155 new_messages.push(reminder_msg);
1156 }
1157 }
1158 }
1159
1160 emit(AgentEvent::TurnEnd {
1161 turn_number,
1162 assistant_message: Message::Assistant(assistant_message.clone()),
1163 tool_results: tool_results.clone(),
1164 });
1165 events.push(AgentEvent::TurnEnd {
1166 turn_number,
1167 assistant_message: Message::Assistant(assistant_message.clone()),
1168 tool_results: tool_results.clone(),
1169 });
1170
1171 if should_stop_after_turn(&self.external_stop) {
1172 tracing::info!("[AGENT-LOOP] external_stop, ending loop");
1173 return Ok((messages, events));
1174 }
1175
1176 pending_messages = self.drain_steering_queue();
1177 tracing::info!(
1178 "[AGENT-LOOP] TurnEnd complete, pending_messages={}, has_more_tool_calls={}",
1179 !pending_messages.is_empty(),
1180 has_more_tool_calls
1181 );
1182
1183 if self.external_stop.load(Ordering::SeqCst) {
1186 tracing::info!(
1187 "[AGENT-LOOP] external_stop set after steering drain, ending loop"
1188 );
1189 return Ok((messages, events));
1190 }
1191 }
1192
1193 let late_steering = self.drain_steering_queue();
1197 if !late_steering.is_empty() {
1198 tracing::info!(
1199 count = late_steering.len(),
1200 "[AGENT-LOOP] Caught late steering messages after inner loop exit"
1201 );
1202 pending_messages = late_steering;
1203 continue;
1204 }
1205
1206 let follow_up_messages = self.drain_follow_up_queue();
1207 if !follow_up_messages.is_empty() {
1208 pending_messages = follow_up_messages;
1209 continue;
1210 }
1211
1212 let final_steering = self.drain_steering_queue();
1215 if !final_steering.is_empty() {
1216 pending_messages = final_steering;
1217 continue;
1218 }
1219
1220 break;
1221 }
1222
1223 append_only.sync_from(&messages);
1225
1226 Ok((messages, events))
1227 }
1228
1229 fn build_compaction_instruction(&self) -> Option<String> {
1232 let base = self.config.compaction_instruction.as_deref();
1233 let injected = self
1234 .ttsr_engine
1235 .as_ref()
1236 .map(|e| e.injected_records())
1237 .unwrap_or_default();
1238 if injected.is_empty() {
1239 return base.map(|s| s.to_string());
1240 }
1241 let mut instr = base.map(|s| s.to_string()).unwrap_or_default();
1242 instr.push_str("\n\nThe following rules have already been enforced in this session and corrections applied. Do NOT violate them again:");
1243 for (name, _turn) in &injected {
1244 instr.push_str(&format!("\n- {name}"));
1245 }
1246 Some(instr)
1247 }
1248
1249 async fn maybe_compact(&self, messages: &mut Vec<Message>, iteration: usize, emit: &EmitFn) {
1250 let snapshot = self.state.get_state();
1266 let (context_tokens, source_label) = match snapshot.current_token_source() {
1267 TokenSource::Real(n) => (n, "provider-reported"),
1268 TokenSource::Heuristic(n) => (n, "bytes/4 heuristic (cold start)"),
1269 TokenSource::None => (0, "empty"),
1270 };
1271 if let Some(div) = snapshot.last_estimate_divergence
1276 && div > 2.0
1277 {
1278 tracing::warn!(
1279 session_id = ?self.session_id,
1280 divergence = div,
1281 reported = snapshot.last_input_tokens.unwrap_or(0),
1282 estimate = snapshot.last_estimate_at_report.unwrap_or(0),
1283 "Token-count heuristic (bytes/4) diverges from provider-reported usage \
1284 by >2x; CompactionStrategy::Threshold decisions are using the \
1285 provider-reported count (issue #28 gap 2)."
1286 );
1287 }
1288 drop(snapshot);
1289
1290 if !self
1291 .compaction_manager
1292 .should_compact(context_tokens, iteration)
1293 {
1294 return;
1295 }
1296
1297 emit(AgentEvent::Compaction {
1298 event: CompactionEvent::Triggered {
1299 context_tokens,
1300 iteration,
1301 source: source_label.to_string(),
1302 },
1303 });
1304
1305 let messages_to_compact: Vec<Message> = messages.to_vec();
1306 let instruction = self.build_compaction_instruction();
1307
1308 match self
1309 .compaction_manager
1310 .compact_if_needed(
1311 &messages_to_compact,
1312 instruction.as_deref(),
1313 context_tokens,
1314 iteration,
1315 )
1316 .await
1317 {
1318 Ok(Some(compacted)) => {
1319 let start = Instant::now();
1320 let message_count = compacted.compacted_count;
1321
1322 emit(AgentEvent::Compaction {
1323 event: CompactionEvent::Started { message_count },
1324 });
1325
1326 let kept_messages = compacted.kept_messages;
1327 let summary = compacted.summary;
1328 let compacted_count = compacted.compacted_count;
1329
1330 *messages = kept_messages;
1331
1332 let state_msgs = messages.clone();
1333 self.state.update(|s| {
1334 s.replace_messages(state_msgs);
1335 });
1336
1337 let compacted_ctx = CompactedContext {
1338 summary,
1339 kept_messages: Vec::new(),
1340 compacted_count,
1341 };
1342 emit(AgentEvent::Compaction {
1343 event: CompactionEvent::Completed {
1344 result: compacted_ctx.clone(),
1345 duration_ms: start.elapsed().as_millis() as u64,
1346 },
1347 });
1348
1349 if let Some(ref hook) = self.config.on_compaction {
1351 match hook(compacted_ctx).await {
1352 Ok(()) => {
1353 tracing::debug!("Compaction hook completed successfully");
1354 }
1355 Err(e) => {
1356 tracing::warn!(error = %e, "Compaction hook failed");
1357 }
1358 }
1359 }
1360 }
1361 Ok(None) => {}
1362 Err(e) => {
1363 emit(AgentEvent::Compaction {
1364 event: CompactionEvent::Failed {
1365 error: e.to_string(),
1366 },
1367 });
1368 }
1369 }
1370 }
1371
1372 fn resolve_model(&self) -> Result<oxicode_ai::Model> {
1373 self.resolver
1374 .resolve_model(&self.config.model_id)
1375 .ok_or_else(|| Error::msg(format!("Model not found: {}", self.config.model_id)))
1376 }
1377}
1378
1379#[cfg(test)]
1380mod session_id_wiring_tests {
1381 use super::*;
1386 use crate::ProviderResolver;
1387 use crate::agent_loop::config::AgentLoopConfig;
1388 use crate::config::ToolExecutionMode;
1389 use crate::state::SharedState;
1390 use crate::tools::ToolRegistry;
1391 use oxicode_ai::{
1392 CompactionStrategy, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1393 };
1394 use std::future::Future;
1395 use std::pin::Pin;
1396
1397 struct NopProvider;
1398 impl Provider for NopProvider {
1399 fn stream<'a>(
1400 &'a self,
1401 _model: &'a Model,
1402 _context: &'a Context,
1403 _options: Option<StreamOptions>,
1404 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1405 Box::pin(async {
1406 Err(ProviderError::NotImplemented(
1407 "session-id wiring tests never stream".to_string(),
1408 ))
1409 })
1410 }
1411 }
1412
1413 struct NullResolver;
1414 impl ProviderResolver for NullResolver {
1415 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1416 None
1417 }
1418 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1419 None
1420 }
1421 }
1422
1423 fn loop_with(session_id: Option<String>) -> AgentLoop {
1424 let config = AgentLoopConfig {
1425 model_id: "test/model".to_string(),
1426 system_prompt: None,
1427 temperature: 1.0,
1428 max_tokens: 4096,
1429 tool_execution: ToolExecutionMode::Sequential,
1430 compaction_strategy: CompactionStrategy::Disabled,
1431 compaction_instruction: None,
1432 context_window: 128_000,
1433 session_id,
1434 transport: None,
1435 compact_on_start: false,
1436 max_retry_delay_ms: None,
1437 auto_retry_enabled: true,
1438 auto_retry_max_attempts: 3,
1439 auto_retry_base_delay_ms: 1000,
1440 workspace_dir: None,
1441 provider_options: None,
1442 on_compaction: None,
1443 snapshot_store: None,
1444 memory: None,
1445 url_resolver: None,
1446 todo: None,
1447 agent_pool: None,
1448 lsp: None,
1449 ttsr_engine: None,
1450 subagent_runner: None,
1451 subagent_depth: 0,
1452 max_tool_result_bytes: None,
1453 thinking_loop_detection: false, ..Default::default()
1455 };
1456 AgentLoop::new_with_resolver(
1457 Arc::new(NopProvider),
1458 config,
1459 Arc::new(ToolRegistry::new()),
1460 SharedState::new(),
1461 Arc::new(NullResolver),
1462 )
1463 }
1464
1465 #[test]
1471 fn tool_context_inherits_session_id_when_set() {
1472 let loop_ = loop_with(Some("proc-test-session-id".to_string()));
1473 let ctx = loop_.build_tool_context();
1474 assert_eq!(
1475 ctx.session_id.as_deref(),
1476 Some("proc-test-session-id"),
1477 "ToolContext.session_id must inherit AgentConfig.session_id"
1478 );
1479 }
1480
1481 #[test]
1482 fn tool_context_session_id_defaults_to_none() {
1483 let loop_ = loop_with(None);
1484 let ctx = loop_.build_tool_context();
1485 assert!(
1486 ctx.session_id.is_none(),
1487 "default ToolContext.session_id should be None"
1488 );
1489 }
1490}
1491
1492#[cfg(test)]
1495mod truncation_tests {
1496 use super::*;
1497 use crate::agent::ProviderResolver;
1498 use oxicode_ai::{
1499 ContentBlock, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1500 TextContent, ToolResultMessage,
1501 };
1502 use std::future::Future;
1503 use std::pin::Pin;
1504
1505 struct NopProvider;
1506 impl Provider for NopProvider {
1507 fn stream<'a>(
1508 &'a self,
1509 _model: &'a Model,
1510 _context: &'a Context,
1511 _options: Option<StreamOptions>,
1512 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1513 Box::pin(async {
1514 Err(ProviderError::NotImplemented(
1515 "truncation tests never stream".to_string(),
1516 ))
1517 })
1518 }
1519 }
1520
1521 struct NullResolver;
1522 impl ProviderResolver for NullResolver {
1523 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1524 None
1525 }
1526 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1527 None
1528 }
1529 }
1530
1531 fn make_result(text: &str) -> ToolResultMessage {
1532 ToolResultMessage::new(
1533 "tc_test".to_string(),
1534 "test_tool",
1535 vec![ContentBlock::Text(TextContent::new(text.to_string()))],
1536 )
1537 }
1538
1539 fn loop_with_limit(limit: Option<usize>) -> AgentLoop {
1540 let config = AgentLoopConfig {
1541 model_id: "test/model".to_string(),
1542 max_tool_result_bytes: limit,
1543 ..Default::default()
1544 };
1545 AgentLoop::new_with_resolver(
1546 Arc::new(NopProvider),
1547 config,
1548 Arc::new(ToolRegistry::new()),
1549 SharedState::new(),
1550 Arc::new(NullResolver),
1551 )
1552 }
1553
1554 #[test]
1555 fn truncate_passthrough_when_none() {
1556 let loop_ = loop_with_limit(None);
1557 let result = make_result(&"x".repeat(10_000));
1558 let truncated = loop_.maybe_truncate_tool_result(result);
1559 if let ContentBlock::Text(tc) = &truncated.content[0] {
1560 assert_eq!(tc.text.len(), 10_000);
1561 assert!(!tc.text.contains("truncated"));
1562 }
1563 }
1564
1565 #[test]
1566 fn truncate_passthrough_when_under_limit() {
1567 let loop_ = loop_with_limit(Some(1000));
1568 let result = make_result(&"x".repeat(500));
1569 let truncated = loop_.maybe_truncate_tool_result(result);
1570 if let ContentBlock::Text(tc) = &truncated.content[0] {
1571 assert_eq!(tc.text.len(), 500);
1572 assert!(!tc.text.contains("truncated"));
1573 }
1574 }
1575
1576 #[test]
1577 fn truncate_applies_when_over_limit() {
1578 let loop_ = loop_with_limit(Some(100));
1579 let result = make_result(&"x".repeat(500));
1580 let truncated = loop_.maybe_truncate_tool_result(result);
1581 if let ContentBlock::Text(tc) = &truncated.content[0] {
1582 assert!(
1583 tc.text.len() < 500,
1584 "text not truncated: {} bytes",
1585 tc.text.len()
1586 );
1587 assert!(tc.text.contains("truncated"), "missing truncation marker");
1588 assert!(tc.text.contains("400 bytes omitted"));
1589 }
1590 }
1591}