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