Skip to main content

oxicode_agent/agent_loop/
mod.rs

1#![allow(unused_doc_comments)]
2
3//! Agent loop — the main request/response cycle driver.
4//!
5//! Coordinates the interaction between the agent, provider, tools, and
6//! state management. Handles streaming, tool execution, retry logic,
7//! and compaction events.
8
9/// Template for the `<system-interrupt>` body injected when a TTSR
10/// rule fires. The `{}` placeholders are filled by `format!` at
11/// interrupt time (`{name}` = `rule.name`, `{content}` =
12/// `rule.content`). Single source of truth for the on-the-wire
13/// interrupt message; `prompts/ttsr-interrupt.md` mirrors this content
14/// verbatim so design docs and live behavior stay aligned.
15const TTSR_INTERRUPT_TEMPLATE: &str = include_str!("../prompts/ttsr-interrupt.md");
16
17/// Append-only context for stable prefix caching.
18pub mod append_only;
19/// Agent-loop configuration.
20pub mod config;
21/// Miscellaneous helper functions.
22pub mod helpers;
23/// Internal message/event queues.
24pub mod queues;
25/// Retry logic for the agent loop.
26pub mod retry;
27/// Stream outcome types for TTSR integration.
28pub mod stream_outcome;
29/// Streaming response handling.
30pub mod streaming;
31/// Tool execution strategies.
32pub mod tool_exec;
33/// Time-Traveling Stream Rules engine.
34pub mod ttsr;
35
36// Re-export for sibling module access
37use 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
67/// AgentLoop.
68pub 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    /// Notify used to wake up the auto-retry sleep immediately when cancelled.
82    auto_retry_notify: tokio::sync::Notify,
83    /// External stop flag — when set, should_stop_after_turn returns true.
84    /// Used by Agent to forward the should_stop_flag from AgentHooks.
85    external_stop: Arc<AtomicBool>,
86    /// Direct cancel signal shared with `Agent::cancel_flag`.
87    /// Set by `Agent::cancel()` and checked by the streaming loop's periodic
88    /// timer so cancellation is detected even when no stream events arrive.
89    cancel_signal: Option<Arc<AtomicBool>>,
90    /// External auto-retry enabled override — when set, the retry layer reads
91    /// this shared flag instead of `config.auto_retry_enabled`, enabling a
92    /// runtime toggle (RPC `set_auto_retry`). Mirrors `cancel_signal`.
93    auto_retry_enabled_override: Option<Arc<AtomicBool>>,
94    /// External auto-retry cancel signal shared with `Agent` (RPC `abort_retry`).
95    auto_retry_cancel_signal: Option<Arc<AtomicBool>>,
96    /// External auto-retry notify shared with `Agent` for immediate wake-up.
97    auto_retry_notify_signal: Option<Arc<tokio::sync::Notify>>,
98    /// Provider/model resolver for isolated model lookups.
99    resolver: Arc<dyn ProviderResolver>,
100    /// Steering hook from AgentHooks — polled each turn to drain new messages
101    /// from AgentSession's queue into AgentLoop's internal steering_queue.
102    steering_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
103    /// Follow-up hook from AgentHooks — same as steering but for follow-ups.
104    follow_up_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
105    /// TTSR engine for stream rule checking.
106    ttsr_engine: Option<Arc<ttsr::TtsrEngine>>,
107    /// Thinking-loop detector — fed every thinking delta. When a loop
108    /// is recognised the stream is aborted with a transient error so
109    /// the retry layer resamples. Gated by
110    /// [`AgentLoopConfig::thinking_loop_detection`] (default on).
111    thinking_loop_detector:
112        parking_lot::Mutex<Option<oxicode_ai::utils::thinking_loop::ThinkingLoopDetector>>,
113    /// Cross-turn tool-call loop guard. Records each completed assistant
114    /// turn; when the same single-tool call repeats past threshold the
115    /// agent injects a steering message to break the loop (omp
116    /// `TERMINAL_TOOL_RESULT_ABORT_REASON` pattern).
117    tool_call_loop_guard: parking_lot::Mutex<oxicode_ai::utils::tool_call_loop::ToolCallLoopGuard>,
118    /// Soft requirement state — tracks which tools have been reminded.
119    soft_requirement_state: parking_lot::Mutex<crate::agent_loop::config::SoftRequirementState>,
120}
121
122impl AgentLoop {
123    /// Creates a new `AgentLoop` with an explicit provider resolver.
124    /// Use this when the model ID needs to be resolved to a provider+model pair
125    /// using custom logic (e.g., per-session routing).
126    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    /// Create a new AgentLoop using the global resolver (backward compat).
185    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    /// Registers a hook called before every tool execution.
202    /// The hook can inspect and modify tool arguments, or reject the call entirely.
203    pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
204        self.before_tool_call = Some(hook);
205        self
206    }
207
208    /// Registers a hook called after every tool execution.
209    /// The hook receives the tool name, arguments, and result.
210    pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
211        self.after_tool_call = Some(hook);
212        self
213    }
214
215    /// Inject a steering message into the agent loop.
216    ///
217    /// Steering messages are processed at the start of each turn, before the
218    /// next LLM call. If the steering queue is at capacity (256 messages), the
219    /// message is dropped and a warning is logged.
220    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    /// Enqueue a follow-up message to continue the conversation after all
227    /// tool calls in the current batch are complete.
228    ///
229    /// If the follow-up queue is at capacity (64 messages), the message is
230    /// dropped and a warning is logged.
231    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    /// Removes all pending steering messages from the queue.
238    /// See [`steer()`](Self::steer) for an explanation of steering messages.
239    pub fn clear_steering_queue(&self) {
240        clear_steering_queue(self);
241    }
242
243    /// Removes all pending follow-up messages from the queue.
244    /// See [`follow_up()`](Self::follow_up) for an explanation of follow-up messages.
245    pub fn clear_follow_up_queue(&self) {
246        clear_follow_up_queue(self);
247    }
248
249    /// Removes all pending messages from both the steering and follow-up queues.
250    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    /// Build a ToolContext from the agent loop config.
259    /// Uses workspace_dir from config if set, otherwise falls back to current directory.
260    #[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    /// Truncate a tool result's text content if it exceeds
284    /// `config.max_tool_result_bytes` (issue #28 gap 1).
285    ///
286    /// When the limit is `None`, the result is returned unchanged.
287    /// When set, any `ContentBlock::Text` block whose `text` field
288    /// exceeds the limit is truncated to the limit and a marker is
289    /// appended so the model knows content was omitted.
290    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    /// Cancels any in-progress auto-retry countdown.
319    /// After calling this, the agent will not automatically retry
320    /// on the next turn.
321    pub fn cancel_auto_retry(&self) {
322        cancel_auto_retry(self);
323    }
324
325    /// Returns the current auto-retry attempt number (0-based).
326    /// Useful for displaying retry status in the UI.
327    pub fn auto_retry_attempt(&self) -> usize {
328        auto_retry_attempt_method(self)
329    }
330
331    /// Get a reference to the shared state.
332    /// Used by Agent to sync state after loop execution.
333    pub fn state(&self) -> &SharedState {
334        &self.state
335    }
336
337    /// Get the external stop flag.
338    pub fn external_stop(&self) -> &Arc<AtomicBool> {
339        &self.external_stop
340    }
341
342    /// Sets a shared cancel signal (typically `Agent::cancel_flag`).
343    /// The streaming loop checks this in its periodic wake-up timer,
344    /// ensuring cancellation is detected even when the provider stream
345    /// produces no events (e.g. waiting for first token).
346    pub fn set_cancel_signal(&mut self, flag: Arc<AtomicBool>) {
347        self.cancel_signal = Some(flag);
348    }
349    /// Install shared auto-retry state (enabled flag + cancel + notify) so the
350    /// owning `Agent` can toggle auto-retry and abort an in-progress retry at
351    /// runtime. Called once per run, mirroring [`Self::set_cancel_signal`].
352    /// Also defensively resets the cancel flag so a prior `abort_retry` does
353    /// not bleed into this run (retry.rs resets it before each wait regardless).
354    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    /// Whether auto-retry is currently enabled — the installed override flag
367    /// if present (runtime toggle), else the config default.
368    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    /// Combined auto-retry cancel state (internal OR external signal).
375    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    /// Reset both internal and external cancel flags before a retry wait.
384    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    /// Fire cancellation on both internal and external signals (set flags +
392    /// wake all waiters on both notifies).
393    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    /// A future that resolves when the external auto-retry notify fires, or
405    /// never if no external notify is installed. Awaited alongside the
406    /// internal notify in the retry sleep `select!`.
407    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    /// Returns a clone of the loop's cancel-signal flag, if one has been
415    /// installed via [`Self::set_cancel_signal`]. Used by tool execution
416    /// to bridge the loop's `AtomicBool` cancellation into the per-tool
417    /// `oneshot::Receiver` cancellation channel (audit finding F-8).
418    pub fn cancel_signal(&self) -> Option<Arc<AtomicBool>> {
419        self.cancel_signal.as_ref().map(Arc::clone)
420    }
421
422    /// Returns true if cancellation has been requested via either
423    /// `external_stop` or the direct `cancel_signal`.
424    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    /// Request cancellation from outside the loop (e.g. Ctrl+C).
433    /// Sets the `external_stop` flag which causes the streaming loop
434    /// to abort on its next periodic check (~500ms) and the agent loop
435    /// to exit after the current turn.
436    pub fn cancel(&self) {
437        self.external_stop.store(true, Ordering::SeqCst);
438    }
439
440    /// Set the steering hook — called each turn to drain new messages
441    /// from the session's steering queue into the loop's internal queue.
442    pub fn set_steering_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
443        self.steering_hook = Some(hook);
444    }
445
446    /// Set the follow-up hook — called each turn to drain new messages
447    /// from the session's follow-up queue into the loop's internal queue.
448    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    /// Poll the steering/follow-up hooks and inject new messages
453    /// into the internal queues.
454    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    /// Runs the agent loop with a single user prompt.
468    /// Convenience wrapper around [`run_messages()`](Self::run_messages).
469    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    /// Run with an explicit initial [`Message`] (e.g. a user message carrying
480    /// image content blocks) instead of a plain-text prompt. Thin wrapper
481    /// over [`run_messages()`](Self::run_messages), mirroring [`run()`](Self::run).
482    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    /// Run with an `FnMut` callback and mutable state — no `Arc<Mutex<>>` needed.
492    ///
493    /// Unlike [`run()`](Self::run), which takes `Fn`, this method accepts `FnMut`
494    /// and a user-provided state value `S`. The callback receives `&mut S` on each
495    /// event, so you can accumulate results without any locking overhead.
496    ///
497    /// Returns the collected events **and** the final state value.
498    ///
499    /// # Example
500    ///
501    /// ```ignore
502    /// #[derive(Default)]
503    /// struct MyState { steps: usize, output: String }
504    ///
505    /// let (events, state) = agent_loop.run_mut(
506    ///     "do something".into(),
507    ///     MyState::default(),
508    ///     |event, s| {
509    ///         match event {
510    ///             AgentEvent::ToolExecutionEnd { is_error: false, .. } => s.steps += 1,
511    ///             AgentEvent::AgentEnd { messages, .. } => {
512    ///                 if let Some(Message::Assistant(a)) = messages.last() {
513    ///                     s.output = a.text_content();
514    ///                 }
515    ///             }
516    ///             _ => {}
517    ///         }
518    ///     },
519    /// ).await?;
520    /// ```
521    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        // Clone the Arc for the closure; the original stays for recovery after run.
531        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        // Recover the state. After run_inner completes, emit_fn is dropped,
542        // releasing the last Arc clone. Arc::try_unwrap should succeed since
543        // only our `state_arc` reference remains.
544        // SAFETY: the doc comment above proves single-ownership after run;
545        // a failure here means a real reference leak that must not be masked.
546        #[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    /// Internal: create the initial user message and delegate to run_messages.
553    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    /// Runs the agent loop with a list of pre-constructed messages.
559    /// This is the primary entry point for executing agent turns.
560    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        // Sync messages back to shared state
596        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    /// Resumes the agent loop after a previous turn ended in a paused state
615    /// (e.g., waiting for user confirmation). Emits events to the provided callback.
616    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    /// Process pending steering messages, emitting events and appending to message history.
661    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    /// Handle a streaming error by synthesizing an error message and completing the turn.
697    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        // Return Ok — lifecycle is complete
747        (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        // Append-only context for prefix-stable message management.
767        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                // Poll external hooks each turn to drain new steering/follow-up
801                // messages injected since the last turn.
802                self.poll_external_queues();
803
804                self.maybe_compact(&mut messages, turn_number as usize, &emit)
805                    .await;
806
807                // Keep the append-only context in sync with messages.
808                // After compaction, messages may have been replaced entirely.
809                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                        // Check for message-ordering errors that can be recovered
822                        // by removing orphaned tool results.
823                        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                                // Don't push the error message to history; retry the turn.
838                                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; // Retry the turn with sanitized messages
846                            }
847                        }
848
849                        // Unrecoverable — fall through to error handler.
850                        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                        // Render the interrupt message from the
877                        // shared `TTSR_INTERRUPT_TEMPLATE` so the
878                        // `prompts/ttsr-interrupt.md` file is the
879                        // single source of truth (no inline drift).
880                        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                            // Tool execution failed — emit TurnEnd and return Ok.
981                            // The lifecycle must always complete.
982                            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                    // Feed completed turn to the tool-call loop guard (omp pattern).
1019                    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                        // NOTE: assign to a variable first so the MutexGuard
1058                        // from lock() is dropped at the semicolon. The if-let
1059                        // pattern would keep the guard alive inside the block,
1060                        // and calling reset() inside would deadlock on the
1061                        // non-reentrant parking_lot::Mutex.
1062                        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                // ── Soft requirement check ──
1085                // After tool execution, check if all soft-required tools were called.
1086                // First miss → reminder steering message. Second miss → escalation.
1087                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                // Early stop check: if external_stop was set (e.g. Ctrl+C),
1184                // don't process steering messages from the next turn.
1185                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            // Re-check steering queue after the inner while loop exits.
1194            // This closes the race window where steer() is called between the
1195            // last drain_steering_queue() and the while-exit condition check.
1196            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            // Final check: one more steering drain after follow-up to catch
1213            // messages injected during the follow-up drain window.
1214            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        // Final sync: keep append-only context consistent.
1224        append_only.sync_from(&messages);
1225
1226        Ok((messages, events))
1227    }
1228
1229    /// Build the compaction instruction, appending injected TTSR rule
1230    /// names so that the model remembers rules already enforced.
1231    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        // Decide the context-size value to drive compaction with. Prefer
1251        // the provider-reported `last_input_tokens` (ground truth) over
1252        // the legacy `bytes/4` heuristic. The heuristic can undercount
1253        // by 3-4× on token-dense content (base64, JSON, CJK) and is the
1254        // reason `CompactionStrategy::Threshold` was effectively a no-op
1255        // in issue #28's failure (35k estimated vs 122k actual).
1256        //
1257        // The provider count lags by exactly one turn: `maybe_compact`
1258        // runs at the **top** of a turn, before streaming. So the
1259        // `last_input_tokens` we read here reflects the *previous*
1260        // turn's `Done` event. The drift is at most the size of the
1261        // tool results the model is about to receive, which is small
1262        // relative to the failure-mode drift the heuristic suffers.
1263        // For turn 1 there is no prior count, so we fall back to the
1264        // heuristic on cold start.
1265        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        // Surface heuristic drift as a warning when the operator has
1272        // observed at least one provider count and it diverges from
1273        // the estimate by more than 2×. This is the diagnostic path
1274        // from #28's "Proposed fix" option 3.
1275        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                // Async compaction hook — awaited, not fire-and-forget.
1350                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    //! Regression coverage for the #13 fix.
1382    //! `build_tool_context` is private; testing it here keeps the test in the
1383    //! same module so it can reach private surface. We never stream — the
1384    //! nop provider only exists to satisfy `AgentLoop::new_with_resolver`.
1385    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, // disable for unit tests
1454            ..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    /// Regression for defect #13: `AgentLoopConfig.session_id` MUST flow into
1466    /// `ToolContext.session_id`. Before the fix, the field was hardcoded to
1467    /// `None`, so the `issue` tool received an empty caller id and bypassed
1468    /// all ownership/liveness checks (two agents could both `start` the same
1469    /// issue and the last writer silently won).
1470    #[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// ── Gap 1: tool-result truncation tests (issue #28) ──────────────────
1493
1494#[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}