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/// Mechanical (LLM-free) context compaction strategies.
20pub mod compaction;
21/// Agent-loop configuration.
22pub mod config;
23/// Miscellaneous helper functions.
24pub mod helpers;
25/// Internal message/event queues.
26pub mod queues;
27/// Retry logic for the agent loop.
28pub mod retry;
29/// Stream outcome types for TTSR integration.
30pub mod stream_outcome;
31/// Streaming response handling.
32pub mod streaming;
33/// Tool execution strategies.
34pub mod tool_exec;
35/// Time-Traveling Stream Rules engine.
36pub mod ttsr;
37
38// Re-export for sibling module access
39use 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
69/// AgentLoop.
70pub 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    /// Notify used to wake up the auto-retry sleep immediately when cancelled.
84    auto_retry_notify: tokio::sync::Notify,
85    /// External stop flag — when set, should_stop_after_turn returns true.
86    /// Used by Agent to forward the should_stop_flag from AgentHooks.
87    external_stop: Arc<AtomicBool>,
88    /// Direct cancel signal shared with `Agent::cancel_flag`.
89    /// Set by `Agent::cancel()` and checked by the streaming loop's periodic
90    /// timer so cancellation is detected even when no stream events arrive.
91    cancel_signal: Option<Arc<AtomicBool>>,
92    /// External auto-retry enabled override — when set, the retry layer reads
93    /// this shared flag instead of `config.auto_retry_enabled`, enabling a
94    /// runtime toggle (RPC `set_auto_retry`). Mirrors `cancel_signal`.
95    auto_retry_enabled_override: Option<Arc<AtomicBool>>,
96    /// External auto-retry cancel signal shared with `Agent` (RPC `abort_retry`).
97    auto_retry_cancel_signal: Option<Arc<AtomicBool>>,
98    /// External auto-retry notify shared with `Agent` for immediate wake-up.
99    auto_retry_notify_signal: Option<Arc<tokio::sync::Notify>>,
100    /// Provider/model resolver for isolated model lookups.
101    resolver: Arc<dyn ProviderResolver>,
102    /// Steering hook from AgentHooks — polled each turn to drain new messages
103    /// from AgentSession's queue into AgentLoop's internal steering_queue.
104    steering_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
105    /// Follow-up hook from AgentHooks — same as steering but for follow-ups.
106    follow_up_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
107    /// TTSR engine for stream rule checking.
108    ttsr_engine: Option<Arc<ttsr::TtsrEngine>>,
109    /// Thinking-loop detector — fed every thinking delta. When a loop
110    /// is recognised the stream is aborted with a transient error so
111    /// the retry layer resamples. Gated by
112    /// [`AgentLoopConfig::thinking_loop_detection`] (default on).
113    thinking_loop_detector:
114        parking_lot::Mutex<Option<oxicode_ai::utils::thinking_loop::ThinkingLoopDetector>>,
115    /// Cross-turn tool-call loop guard. Records each completed assistant
116    /// turn; when the same single-tool call repeats past threshold the
117    /// agent injects a steering message to break the loop (omp
118    /// `TERMINAL_TOOL_RESULT_ABORT_REASON` pattern).
119    tool_call_loop_guard: parking_lot::Mutex<oxicode_ai::utils::tool_call_loop::ToolCallLoopGuard>,
120    /// Soft requirement state — tracks which tools have been reminded.
121    soft_requirement_state: parking_lot::Mutex<crate::agent_loop::config::SoftRequirementState>,
122}
123
124impl AgentLoop {
125    /// Creates a new `AgentLoop` with an explicit provider resolver.
126    /// Use this when the model ID needs to be resolved to a provider+model pair
127    /// using custom logic (e.g., per-session routing).
128    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    /// Create a new AgentLoop using the global resolver (backward compat).
187    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    /// Registers a hook called before every tool execution.
204    /// The hook can inspect and modify tool arguments, or reject the call entirely.
205    pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
206        self.before_tool_call = Some(hook);
207        self
208    }
209
210    /// Registers a hook called after every tool execution.
211    /// The hook receives the tool name, arguments, and result.
212    pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
213        self.after_tool_call = Some(hook);
214        self
215    }
216
217    /// Inject a steering message into the agent loop.
218    ///
219    /// Steering messages are processed at the start of each turn, before the
220    /// next LLM call. If the steering queue is at capacity (256 messages), the
221    /// message is dropped and a warning is logged.
222    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    /// Enqueue a follow-up message to continue the conversation after all
229    /// tool calls in the current batch are complete.
230    ///
231    /// If the follow-up queue is at capacity (64 messages), the message is
232    /// dropped and a warning is logged.
233    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    /// Removes all pending steering messages from the queue.
240    /// See [`steer()`](Self::steer) for an explanation of steering messages.
241    pub fn clear_steering_queue(&self) {
242        clear_steering_queue(self);
243    }
244
245    /// Removes all pending follow-up messages from the queue.
246    /// See [`follow_up()`](Self::follow_up) for an explanation of follow-up messages.
247    pub fn clear_follow_up_queue(&self) {
248        clear_follow_up_queue(self);
249    }
250
251    /// Removes all pending messages from both the steering and follow-up queues.
252    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    /// Build a ToolContext from the agent loop config.
261    /// Uses workspace_dir from config if set, otherwise falls back to current directory.
262    #[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    /// Truncate a tool result's text content if it exceeds
286    /// `config.max_tool_result_bytes` (issue #28 gap 1).
287    ///
288    /// When the limit is `None`, the result is returned unchanged.
289    /// When set, any `ContentBlock::Text` block whose `text` field
290    /// exceeds the limit is truncated to the limit and a marker is
291    /// appended so the model knows content was omitted.
292    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    /// Cancels any in-progress auto-retry countdown.
321    /// After calling this, the agent will not automatically retry
322    /// on the next turn.
323    pub fn cancel_auto_retry(&self) {
324        cancel_auto_retry(self);
325    }
326
327    /// Returns the current auto-retry attempt number (0-based).
328    /// Useful for displaying retry status in the UI.
329    pub fn auto_retry_attempt(&self) -> usize {
330        auto_retry_attempt_method(self)
331    }
332
333    /// Get a reference to the shared state.
334    /// Used by Agent to sync state after loop execution.
335    pub fn state(&self) -> &SharedState {
336        &self.state
337    }
338
339    /// Get the external stop flag.
340    pub fn external_stop(&self) -> &Arc<AtomicBool> {
341        &self.external_stop
342    }
343
344    /// Sets a shared cancel signal (typically `Agent::cancel_flag`).
345    /// The streaming loop checks this in its periodic wake-up timer,
346    /// ensuring cancellation is detected even when the provider stream
347    /// produces no events (e.g. waiting for first token).
348    pub fn set_cancel_signal(&mut self, flag: Arc<AtomicBool>) {
349        self.cancel_signal = Some(flag);
350    }
351    /// Install shared auto-retry state (enabled flag + cancel + notify) so the
352    /// owning `Agent` can toggle auto-retry and abort an in-progress retry at
353    /// runtime. Called once per run, mirroring [`Self::set_cancel_signal`].
354    /// Also defensively resets the cancel flag so a prior `abort_retry` does
355    /// not bleed into this run (retry.rs resets it before each wait regardless).
356    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    /// Whether auto-retry is currently enabled — the installed override flag
369    /// if present (runtime toggle), else the config default.
370    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    /// Combined auto-retry cancel state (internal OR external signal).
377    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    /// Reset both internal and external cancel flags before a retry wait.
386    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    /// Fire cancellation on both internal and external signals (set flags +
394    /// wake all waiters on both notifies).
395    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    /// A future that resolves when the external auto-retry notify fires, or
407    /// never if no external notify is installed. Awaited alongside the
408    /// internal notify in the retry sleep `select!`.
409    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    /// Returns a clone of the loop's cancel-signal flag, if one has been
417    /// installed via [`Self::set_cancel_signal`]. Used by tool execution
418    /// to bridge the loop's `AtomicBool` cancellation into the per-tool
419    /// `oneshot::Receiver` cancellation channel (audit finding F-8).
420    pub fn cancel_signal(&self) -> Option<Arc<AtomicBool>> {
421        self.cancel_signal.as_ref().map(Arc::clone)
422    }
423
424    /// Returns true if cancellation has been requested via either
425    /// `external_stop` or the direct `cancel_signal`.
426    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    /// Request cancellation from outside the loop (e.g. Ctrl+C).
435    /// Sets the `external_stop` flag which causes the streaming loop
436    /// to abort on its next periodic check (~500ms) and the agent loop
437    /// to exit after the current turn.
438    pub fn cancel(&self) {
439        self.external_stop.store(true, Ordering::SeqCst);
440    }
441
442    /// Set the steering hook — called each turn to drain new messages
443    /// from the session's steering queue into the loop's internal queue.
444    pub fn set_steering_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
445        self.steering_hook = Some(hook);
446    }
447
448    /// Set the follow-up hook — called each turn to drain new messages
449    /// from the session's follow-up queue into the loop's internal queue.
450    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    /// Poll the steering/follow-up hooks and inject new messages
455    /// into the internal queues.
456    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    /// Runs the agent loop with a single user prompt.
470    /// Convenience wrapper around [`run_messages()`](Self::run_messages).
471    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    /// Run with an explicit initial [`Message`] (e.g. a user message carrying
482    /// image content blocks) instead of a plain-text prompt. Thin wrapper
483    /// over [`run_messages()`](Self::run_messages), mirroring [`run()`](Self::run).
484    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    /// Run with an `FnMut` callback and mutable state — no `Arc<Mutex<>>` needed.
494    ///
495    /// Unlike [`run()`](Self::run), which takes `Fn`, this method accepts `FnMut`
496    /// and a user-provided state value `S`. The callback receives `&mut S` on each
497    /// event, so you can accumulate results without any locking overhead.
498    ///
499    /// Returns the collected events **and** the final state value.
500    ///
501    /// # Example
502    ///
503    /// ```ignore
504    /// #[derive(Default)]
505    /// struct MyState { steps: usize, output: String }
506    ///
507    /// let (events, state) = agent_loop.run_mut(
508    ///     "do something".into(),
509    ///     MyState::default(),
510    ///     |event, s| {
511    ///         match event {
512    ///             AgentEvent::ToolExecutionEnd { is_error: false, .. } => s.steps += 1,
513    ///             AgentEvent::AgentEnd { messages, .. } => {
514    ///                 if let Some(Message::Assistant(a)) = messages.last() {
515    ///                     s.output = a.text_content();
516    ///                 }
517    ///             }
518    ///             _ => {}
519    ///         }
520    ///     },
521    /// ).await?;
522    /// ```
523    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        // Clone the Arc for the closure; the original stays for recovery after run.
533        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        // Recover the state. After run_inner completes, emit_fn is dropped,
544        // releasing the last Arc clone. Arc::try_unwrap should succeed since
545        // only our `state_arc` reference remains.
546        // SAFETY: the doc comment above proves single-ownership after run;
547        // a failure here means a real reference leak that must not be masked.
548        #[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    /// Internal: create the initial user message and delegate to run_messages.
555    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    /// Runs the agent loop with a list of pre-constructed messages.
561    /// This is the primary entry point for executing agent turns.
562    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        // Sync messages back to shared state
598        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    /// Resumes the agent loop after a previous turn ended in a paused state
617    /// (e.g., waiting for user confirmation). Emits events to the provided callback.
618    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    /// Process pending steering messages, emitting events and appending to message history.
663    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    /// Handle a streaming error by synthesizing an error message and completing the turn.
699    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        // Return Ok — lifecycle is complete
749        (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        // Append-only context for prefix-stable message management.
769        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                // Poll external hooks each turn to drain new steering/follow-up
803                // messages injected since the last turn.
804                self.poll_external_queues();
805
806                self.maybe_compact(&mut messages, turn_number as usize, &emit)
807                    .await;
808
809                // Keep the append-only context in sync with messages.
810                // After compaction, messages may have been replaced entirely.
811                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                        // Check for message-ordering errors that can be recovered
824                        // by removing orphaned tool results.
825                        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                                // Don't push the error message to history; retry the turn.
840                                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; // Retry the turn with sanitized messages
848                            }
849                        }
850
851                        // Unrecoverable — fall through to error handler.
852                        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                        // Render the interrupt message from the
879                        // shared `TTSR_INTERRUPT_TEMPLATE` so the
880                        // `prompts/ttsr-interrupt.md` file is the
881                        // single source of truth (no inline drift).
882                        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                            // Tool execution failed — emit TurnEnd and return Ok.
983                            // The lifecycle must always complete.
984                            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                    // Feed completed turn to the tool-call loop guard (omp pattern).
1021                    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                        // NOTE: assign to a variable first so the MutexGuard
1060                        // from lock() is dropped at the semicolon. The if-let
1061                        // pattern would keep the guard alive inside the block,
1062                        // and calling reset() inside would deadlock on the
1063                        // non-reentrant parking_lot::Mutex.
1064                        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                // ── Soft requirement check ──
1087                // After tool execution, check if all soft-required tools were called.
1088                // First miss → reminder steering message. Second miss → escalation.
1089                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                // Early stop check: if external_stop was set (e.g. Ctrl+C),
1186                // don't process steering messages from the next turn.
1187                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            // Re-check steering queue after the inner while loop exits.
1196            // This closes the race window where steer() is called between the
1197            // last drain_steering_queue() and the while-exit condition check.
1198            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            // Final check: one more steering drain after follow-up to catch
1215            // messages injected during the follow-up drain window.
1216            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        // Final sync: keep append-only context consistent.
1226        append_only.sync_from(&messages);
1227
1228        Ok((messages, events))
1229    }
1230
1231    /// Build the compaction instruction, appending injected TTSR rule
1232    /// names so that the model remembers rules already enforced.
1233    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        // Decide the context-size value to drive compaction with. Prefer
1253        // the provider-reported `last_input_tokens` (ground truth) over
1254        // the legacy `bytes/4` heuristic. The heuristic can undercount
1255        // by 3-4× on token-dense content (base64, JSON, CJK) and is the
1256        // reason `CompactionStrategy::Threshold` was effectively a no-op
1257        // in issue #28's failure (35k estimated vs 122k actual).
1258        //
1259        // The provider count lags by exactly one turn: `maybe_compact`
1260        // runs at the **top** of a turn, before streaming. So the
1261        // `last_input_tokens` we read here reflects the *previous*
1262        // turn's `Done` event. The drift is at most the size of the
1263        // tool results the model is about to receive, which is small
1264        // relative to the failure-mode drift the heuristic suffers.
1265        // For turn 1 there is no prior count, so we fall back to the
1266        // heuristic on cold start.
1267        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        // Surface heuristic drift as a warning when the operator has
1274        // observed at least one provider count and it diverges from
1275        // the estimate by more than 2×. This is the diagnostic path
1276        // from #28's "Proposed fix" option 3.
1277        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        // ── Mechanical shake (LLM-free) ────────────────────────────
1300        // Try eliding large tool results / code blocks BEFORE invoking
1301        // the LLM compactor. If enough tokens are recovered, skip the
1302        // expensive LLM round-trip entirely.
1303        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; // shake recovered enough — no LLM compaction needed
1328            }
1329            compaction::shake::ShakeOutcome::NoChange => {
1330                // Not enough elidable content — fall through to LLM compaction.
1331            }
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                // Async compaction hook — awaited, not fire-and-forget.
1387                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    //! Regression coverage for the #13 fix.
1419    //! `build_tool_context` is private; testing it here keeps the test in the
1420    //! same module so it can reach private surface. We never stream — the
1421    //! nop provider only exists to satisfy `AgentLoop::new_with_resolver`.
1422    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, // disable for unit tests
1491            ..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    /// Regression for defect #13: `AgentLoopConfig.session_id` MUST flow into
1503    /// `ToolContext.session_id`. Before the fix, the field was hardcoded to
1504    /// `None`, so the `issue` tool received an empty caller id and bypassed
1505    /// all ownership/liveness checks (two agents could both `start` the same
1506    /// issue and the last writer silently won).
1507    #[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// ── Gap 1: tool-result truncation tests (issue #28) ──────────────────
1530
1531#[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}