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        // A custom compactor (e.g. `SnapcompactCompactor` from the SDK)
139        // replaces the default LLM compactor entirely. Note this happens
140        // regardless of strategy: the strategy still gates *when* the
141        // automatic path fires (`should_compact`), but a provided
142        // compactor makes manual compaction (`compact_now`) available
143        // even under `CompactionStrategy::Disabled`.
144        if let Some(compactor) = &config.compactor {
145            compaction_manager.set_compactor(Arc::clone(compactor));
146        } else if config.compaction_strategy != CompactionStrategy::Disabled {
147            let model = resolver.resolve_model(&config.model_id);
148            if let Some(model) = model {
149                let llm_compactor =
150                    Arc::new(LlmCompactor::new(model.clone(), Arc::clone(&provider)));
151                compaction_manager.set_compactor(llm_compactor);
152            }
153        }
154
155        Self {
156            provider,
157            config: config.clone(),
158            tools,
159            state,
160            compaction_manager,
161            before_tool_call: None,
162            after_tool_call: None,
163            steering_queue: RwLock::new(Vec::new()),
164            follow_up_queue: RwLock::new(Vec::new()),
165            session_id: config.session_id.clone(),
166            auto_retry_attempt: AtomicUsize::new(0),
167            auto_retry_cancel: AtomicBool::new(false),
168            auto_retry_notify: tokio::sync::Notify::new(),
169            external_stop: Arc::new(AtomicBool::new(false)),
170            cancel_signal: None,
171            auto_retry_enabled_override: None,
172            auto_retry_cancel_signal: None,
173            auto_retry_notify_signal: None,
174            resolver,
175            steering_hook: None,
176            follow_up_hook: None,
177            ttsr_engine: config.ttsr_engine.clone(),
178            thinking_loop_detector: parking_lot::Mutex::new(if config.thinking_loop_detection {
179                Some(oxicode_ai::utils::thinking_loop::ThinkingLoopDetector::new())
180            } else {
181                None
182            }),
183            tool_call_loop_guard: parking_lot::Mutex::new(
184                oxicode_ai::utils::tool_call_loop::ToolCallLoopGuard::new(
185                    config.tool_call_loop_guard.clone(),
186                ),
187            ),
188            soft_requirement_state: parking_lot::Mutex::new(
189                crate::agent_loop::config::SoftRequirementState::default(),
190            ),
191        }
192    }
193
194    /// Create a new AgentLoop using the global resolver (backward compat).
195    pub fn new(
196        provider: Arc<dyn Provider>,
197        config: AgentLoopConfig,
198        tools: Arc<ToolRegistry>,
199        state: SharedState,
200    ) -> Self {
201        use crate::agent::GlobalProviderResolver;
202        Self::new_with_resolver(
203            provider,
204            config,
205            tools,
206            state,
207            Arc::new(GlobalProviderResolver),
208        )
209    }
210
211    /// Registers a hook called before every tool execution.
212    /// The hook can inspect and modify tool arguments, or reject the call entirely.
213    pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
214        self.before_tool_call = Some(hook);
215        self
216    }
217
218    /// Registers a hook called after every tool execution.
219    /// The hook receives the tool name, arguments, and result.
220    pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
221        self.after_tool_call = Some(hook);
222        self
223    }
224
225    /// Inject a steering message into the agent loop.
226    ///
227    /// Steering messages are processed at the start of each turn, before the
228    /// next LLM call. If the steering queue is at capacity (256 messages), the
229    /// message is dropped and a warning is logged.
230    pub fn steer(&self, message: Message) {
231        if !try_push_steering(self, message) {
232            tracing::warn!("Steering message dropped — queue at capacity");
233        }
234    }
235
236    /// Enqueue a follow-up message to continue the conversation after all
237    /// tool calls in the current batch are complete.
238    ///
239    /// If the follow-up queue is at capacity (64 messages), the message is
240    /// dropped and a warning is logged.
241    pub fn follow_up(&self, message: Message) {
242        if !try_push_follow_up(self, message) {
243            tracing::warn!("Follow-up message dropped — queue at capacity");
244        }
245    }
246
247    /// Removes all pending steering messages from the queue.
248    /// See [`steer()`](Self::steer) for an explanation of steering messages.
249    pub fn clear_steering_queue(&self) {
250        clear_steering_queue(self);
251    }
252
253    /// Removes all pending follow-up messages from the queue.
254    /// See [`follow_up()`](Self::follow_up) for an explanation of follow-up messages.
255    pub fn clear_follow_up_queue(&self) {
256        clear_follow_up_queue(self);
257    }
258
259    /// Removes all pending messages from both the steering and follow-up queues.
260    pub fn clear_all_queues(&self) {
261        clear_all_queues(self);
262    }
263
264    fn drain_steering_queue(&self) -> Vec<Message> {
265        drain_steering_queue(self)
266    }
267
268    /// Build a ToolContext from the agent loop config.
269    /// Uses workspace_dir from config if set, otherwise falls back to current directory.
270    #[cfg_attr(test, allow(dead_code))]
271    pub(crate) fn build_tool_context(&self) -> ToolContext {
272        let workspace = self
273            .config
274            .workspace_dir
275            .clone()
276            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
277        ToolContext {
278            workspace_dir: workspace,
279            root_dir: self.config.workspace_dir.clone(),
280            session_id: self.session_id.clone(),
281            snapshot_store: self.config.snapshot_store.clone(),
282            memory: self.config.memory.clone(),
283            url_resolver: self.config.url_resolver.clone(),
284            todo: self.config.todo.clone(),
285            agent_pool: self.config.agent_pool.clone(),
286            lsp: self.config.lsp.clone(),
287            subagent_runner: self.config.subagent_runner.clone(),
288            subagent_depth: self.config.subagent_depth,
289            intent: None,
290        }
291    }
292
293    /// Truncate a tool result's text content if it exceeds
294    /// `config.max_tool_result_bytes` (issue #28 gap 1).
295    ///
296    /// When the limit is `None`, the result is returned unchanged.
297    /// When set, any `ContentBlock::Text` block whose `text` field
298    /// exceeds the limit is truncated to the limit and a marker is
299    /// appended so the model knows content was omitted.
300    fn maybe_truncate_tool_result(
301        &self,
302        mut result: oxicode_ai::ToolResultMessage,
303    ) -> oxicode_ai::ToolResultMessage {
304        let Some(max_bytes) = self.config.max_tool_result_bytes else {
305            return result;
306        };
307
308        for block in &mut result.content {
309            if let oxicode_ai::ContentBlock::Text(tc) = block
310                && tc.text.len() > max_bytes
311            {
312                let omitted = tc.text.len() - max_bytes;
313                tc.text.truncate(max_bytes);
314                tc.text.push_str(&format!(
315                    "\n\n... [truncated: {omitted} bytes omitted, \
316                     use read/grep for full content]"
317                ));
318            }
319        }
320
321        result
322    }
323
324    fn drain_follow_up_queue(&self) -> Vec<Message> {
325        drain_follow_up_queue(self)
326    }
327
328    /// Cancels any in-progress auto-retry countdown.
329    /// After calling this, the agent will not automatically retry
330    /// on the next turn.
331    pub fn cancel_auto_retry(&self) {
332        cancel_auto_retry(self);
333    }
334
335    /// Returns the current auto-retry attempt number (0-based).
336    /// Useful for displaying retry status in the UI.
337    pub fn auto_retry_attempt(&self) -> usize {
338        auto_retry_attempt_method(self)
339    }
340
341    /// Get a reference to the shared state.
342    /// Used by Agent to sync state after loop execution.
343    pub fn state(&self) -> &SharedState {
344        &self.state
345    }
346
347    /// Get the external stop flag.
348    pub fn external_stop(&self) -> &Arc<AtomicBool> {
349        &self.external_stop
350    }
351
352    /// Sets a shared cancel signal (typically `Agent::cancel_flag`).
353    /// The streaming loop checks this in its periodic wake-up timer,
354    /// ensuring cancellation is detected even when the provider stream
355    /// produces no events (e.g. waiting for first token).
356    pub fn set_cancel_signal(&mut self, flag: Arc<AtomicBool>) {
357        self.cancel_signal = Some(flag);
358    }
359    /// Install shared auto-retry state (enabled flag + cancel + notify) so the
360    /// owning `Agent` can toggle auto-retry and abort an in-progress retry at
361    /// runtime. Called once per run, mirroring [`Self::set_cancel_signal`].
362    /// Also defensively resets the cancel flag so a prior `abort_retry` does
363    /// not bleed into this run (retry.rs resets it before each wait regardless).
364    pub fn set_auto_retry_state(
365        &mut self,
366        enabled: Arc<AtomicBool>,
367        cancel: Arc<AtomicBool>,
368        notify: Arc<tokio::sync::Notify>,
369    ) {
370        cancel.store(false, Ordering::SeqCst);
371        self.auto_retry_enabled_override = Some(enabled);
372        self.auto_retry_cancel_signal = Some(cancel);
373        self.auto_retry_notify_signal = Some(notify);
374    }
375
376    /// Whether auto-retry is currently enabled — the installed override flag
377    /// if present (runtime toggle), else the config default.
378    pub(crate) fn auto_retry_enabled(&self) -> bool {
379        self.auto_retry_enabled_override
380            .as_ref()
381            .map_or(self.config.auto_retry_enabled, |f| f.load(Ordering::SeqCst))
382    }
383
384    /// Combined auto-retry cancel state (internal OR external signal).
385    pub(crate) fn auto_retry_cancelled(&self) -> bool {
386        self.auto_retry_cancel.load(Ordering::SeqCst)
387            || self
388                .auto_retry_cancel_signal
389                .as_ref()
390                .is_some_and(|c| c.load(Ordering::SeqCst))
391    }
392
393    /// Reset both internal and external cancel flags before a retry wait.
394    pub(crate) fn reset_auto_retry_cancel(&self) {
395        self.auto_retry_cancel.store(false, Ordering::SeqCst);
396        if let Some(c) = &self.auto_retry_cancel_signal {
397            c.store(false, Ordering::SeqCst);
398        }
399    }
400
401    /// Fire cancellation on both internal and external signals (set flags +
402    /// wake all waiters on both notifies).
403    pub(crate) fn fire_auto_retry_cancel(&self) {
404        self.auto_retry_cancel.store(true, Ordering::SeqCst);
405        if let Some(c) = &self.auto_retry_cancel_signal {
406            c.store(true, Ordering::SeqCst);
407        }
408        self.auto_retry_notify.notify_waiters();
409        if let Some(n) = &self.auto_retry_notify_signal {
410            n.notify_waiters();
411        }
412    }
413
414    /// A future that resolves when the external auto-retry notify fires, or
415    /// never if no external notify is installed. Awaited alongside the
416    /// internal notify in the retry sleep `select!`.
417    pub(crate) async fn external_auto_retry_notified(&self) {
418        match &self.auto_retry_notify_signal {
419            Some(n) => n.notified().await,
420            None => std::future::pending::<()>().await,
421        }
422    }
423
424    /// Returns a clone of the loop's cancel-signal flag, if one has been
425    /// installed via [`Self::set_cancel_signal`]. Used by tool execution
426    /// to bridge the loop's `AtomicBool` cancellation into the per-tool
427    /// `oneshot::Receiver` cancellation channel (audit finding F-8).
428    pub fn cancel_signal(&self) -> Option<Arc<AtomicBool>> {
429        self.cancel_signal.as_ref().map(Arc::clone)
430    }
431
432    /// Returns true if cancellation has been requested via either
433    /// `external_stop` or the direct `cancel_signal`.
434    pub fn is_cancelled(&self) -> bool {
435        if self.external_stop.load(Ordering::SeqCst) {
436            return true;
437        }
438        self.cancel_signal
439            .as_ref()
440            .is_some_and(|f| f.load(Ordering::SeqCst))
441    }
442    /// Request cancellation from outside the loop (e.g. Ctrl+C).
443    /// Sets the `external_stop` flag which causes the streaming loop
444    /// to abort on its next periodic check (~500ms) and the agent loop
445    /// to exit after the current turn.
446    pub fn cancel(&self) {
447        self.external_stop.store(true, Ordering::SeqCst);
448    }
449
450    /// Set the steering hook — called each turn to drain new messages
451    /// from the session's steering queue into the loop's internal queue.
452    pub fn set_steering_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
453        self.steering_hook = Some(hook);
454    }
455
456    /// Set the follow-up hook — called each turn to drain new messages
457    /// from the session's follow-up queue into the loop's internal queue.
458    pub fn set_follow_up_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
459        self.follow_up_hook = Some(hook);
460    }
461
462    /// Poll the steering/follow-up hooks and inject new messages
463    /// into the internal queues.
464    fn poll_external_queues(&self) {
465        if let Some(ref hook) = self.steering_hook {
466            for msg in hook() {
467                self.steer(msg);
468            }
469        }
470        if let Some(ref hook) = self.follow_up_hook {
471            for msg in hook() {
472                self.follow_up(msg);
473            }
474        }
475    }
476
477    /// Runs the agent loop with a single user prompt.
478    /// Convenience wrapper around [`run_messages()`](Self::run_messages).
479    pub async fn run(
480        &self,
481        prompt: String,
482        emit: impl Fn(AgentEvent) + Send + Sync + 'static,
483    ) -> Result<Vec<AgentEvent>> {
484        let message = Message::User(UserMessage::new(prompt));
485        let emit = Arc::new(emit);
486        self.run_messages(vec![message], emit).await
487    }
488
489    /// Run with an explicit initial [`Message`] (e.g. a user message carrying
490    /// image content blocks) instead of a plain-text prompt. Thin wrapper
491    /// over [`run_messages()`](Self::run_messages), mirroring [`run()`](Self::run).
492    pub async fn run_message(
493        &self,
494        message: Message,
495        emit: impl Fn(AgentEvent) + Send + Sync + 'static,
496    ) -> Result<Vec<AgentEvent>> {
497        let emit = Arc::new(emit);
498        self.run_messages(vec![message], emit).await
499    }
500
501    /// Run with an `FnMut` callback and mutable state — no `Arc<Mutex<>>` needed.
502    ///
503    /// Unlike [`run()`](Self::run), which takes `Fn`, this method accepts `FnMut`
504    /// and a user-provided state value `S`. The callback receives `&mut S` on each
505    /// event, so you can accumulate results without any locking overhead.
506    ///
507    /// Returns the collected events **and** the final state value.
508    ///
509    /// # Example
510    ///
511    /// ```ignore
512    /// #[derive(Default)]
513    /// struct MyState { steps: usize, output: String }
514    ///
515    /// let (events, state) = agent_loop.run_mut(
516    ///     "do something".into(),
517    ///     MyState::default(),
518    ///     |event, s| {
519    ///         match event {
520    ///             AgentEvent::ToolExecutionEnd { is_error: false, .. } => s.steps += 1,
521    ///             AgentEvent::AgentEnd { messages, .. } => {
522    ///                 if let Some(Message::Assistant(a)) = messages.last() {
523    ///                     s.output = a.text_content();
524    ///                 }
525    ///             }
526    ///             _ => {}
527    ///         }
528    ///     },
529    /// ).await?;
530    /// ```
531    pub async fn run_mut<S: Send + std::fmt::Debug + 'static>(
532        &self,
533        prompt: String,
534        state: S,
535        emit: impl FnMut(AgentEvent, &mut S) + Send + 'static,
536    ) -> Result<(Vec<AgentEvent>, S)> {
537        let emit_fnmut = Arc::new(parking_lot::Mutex::new(emit));
538        let state_arc = Arc::new(parking_lot::Mutex::new(state));
539
540        // Clone the Arc for the closure; the original stays for recovery after run.
541        let state_for_closure = Arc::clone(&state_arc);
542
543        let emit_fn: EmitFn = Arc::new(move |event: AgentEvent| {
544            let mut cb = emit_fnmut.lock();
545            let mut s = state_for_closure.lock();
546            cb(event, &mut s);
547        });
548
549        let events = self.run_inner(prompt, emit_fn).await?;
550
551        // Recover the state. After run_inner completes, emit_fn is dropped,
552        // releasing the last Arc clone. Arc::try_unwrap should succeed since
553        // only our `state_arc` reference remains.
554        // SAFETY: the doc comment above proves single-ownership after run;
555        // a failure here means a real reference leak that must not be masked.
556        #[allow(clippy::expect_used)]
557        let mutex = Arc::try_unwrap(state_arc)
558            .expect("run_mut: state Arc still has multiple owners after run");
559        Ok((events, mutex.into_inner()))
560    }
561
562    /// Internal: create the initial user message and delegate to run_messages.
563    async fn run_inner(&self, prompt: String, emit: EmitFn) -> Result<Vec<AgentEvent>> {
564        let message = Message::User(UserMessage::new(prompt));
565        self.run_messages(vec![message], emit).await
566    }
567
568    /// Runs the agent loop with a list of pre-constructed messages.
569    /// This is the primary entry point for executing agent turns.
570    pub async fn run_messages(
571        &self,
572        prompts: Vec<Message>,
573        emit: EmitFn,
574    ) -> Result<Vec<AgentEvent>> {
575        let mut all_events = Vec::new();
576
577        let state_messages = self.state.get_state().messages.clone();
578        let mut all_messages = state_messages;
579        all_messages.extend(prompts.clone());
580
581        tracing::info!(session_id = ?self.session_id, "AgentLoop starting");
582        emit(AgentEvent::AgentStart {
583            prompts: prompts.clone(),
584            session_id: self.session_id.clone(),
585        });
586        all_events.push(AgentEvent::AgentStart {
587            prompts: prompts.clone(),
588            session_id: self.session_id.clone(),
589        });
590
591        let (result_messages, events) = self.run_loop(prompts, emit.clone()).await?;
592
593        all_events.extend(events);
594
595        let stop_reason = result_messages.last().and_then(|m| {
596            if let Message::Assistant(a) = m {
597                Some(format!("{:?}", a.stop_reason))
598            } else {
599                None
600            }
601        });
602
603        tracing::info!(session_id = ?self.session_id, "AgentLoop run_messages complete");
604
605        // Sync messages back to shared state
606        self.state.update(|s| {
607            s.replace_messages(result_messages.clone());
608        });
609
610        emit(AgentEvent::AgentEnd {
611            messages: result_messages.clone(),
612            stop_reason: stop_reason.clone(),
613            session_id: self.session_id.clone(),
614        });
615        all_events.push(AgentEvent::AgentEnd {
616            messages: result_messages.clone(),
617            stop_reason,
618            session_id: self.session_id.clone(),
619        });
620
621        Ok(all_events)
622    }
623
624    /// Resumes the agent loop after a previous turn ended in a paused state
625    /// (e.g., waiting for user confirmation). Emits events to the provided callback.
626    pub async fn continue_loop(
627        &self,
628        emit: impl Fn(AgentEvent) + Send + Sync + 'static,
629    ) -> Result<Vec<AgentEvent>> {
630        let emit = Arc::new(emit);
631        let mut all_events = Vec::new();
632
633        tracing::info!(session_id = ?self.session_id, "AgentLoop continuing");
634        emit(AgentEvent::AgentStart {
635            prompts: vec![],
636            session_id: self.session_id.clone(),
637        });
638        all_events.push(AgentEvent::AgentStart {
639            prompts: vec![],
640            session_id: self.session_id.clone(),
641        });
642
643        let (result_messages, events) = self.run_loop(vec![], emit.clone()).await?;
644
645        all_events.extend(events);
646
647        let stop_reason = result_messages.last().and_then(|m| {
648            if let Message::Assistant(a) = m {
649                Some(format!("{:?}", a.stop_reason))
650            } else {
651                None
652            }
653        });
654
655        tracing::info!(session_id = ?self.session_id, "AgentLoop continue_loop complete");
656        emit(AgentEvent::AgentEnd {
657            messages: result_messages.clone(),
658            stop_reason: stop_reason.clone(),
659            session_id: self.session_id.clone(),
660        });
661        all_events.push(AgentEvent::AgentEnd {
662            messages: result_messages.clone(),
663            stop_reason,
664            session_id: self.session_id.clone(),
665        });
666
667        Ok(all_events)
668    }
669
670    /// Process pending steering messages, emitting events and appending to message history.
671    fn process_steering_messages(
672        &self,
673        pending_messages: &mut Vec<Message>,
674        messages: &mut Vec<Message>,
675        new_messages: &mut Vec<Message>,
676        events: &mut Vec<AgentEvent>,
677        emit: &EmitFn,
678    ) {
679        if pending_messages.is_empty() {
680            return;
681        }
682        for message in pending_messages.drain(..) {
683            emit(AgentEvent::SteeringMessage {
684                message: message.clone(),
685            });
686            emit(AgentEvent::MessageStart {
687                message: message.clone(),
688            });
689            emit(AgentEvent::MessageEnd {
690                message: message.clone(),
691            });
692            events.push(AgentEvent::SteeringMessage {
693                message: message.clone(),
694            });
695            events.push(AgentEvent::MessageStart {
696                message: message.clone(),
697            });
698            events.push(AgentEvent::MessageEnd {
699                message: message.clone(),
700            });
701            messages.push(message.clone());
702            new_messages.push(message);
703        }
704    }
705
706    /// Handle a streaming error by synthesizing an error message and completing the turn.
707    async fn handle_streaming_error(
708        &self,
709        e: anyhow::Error,
710        messages: &mut Vec<Message>,
711        new_messages: &mut Vec<Message>,
712        events: &mut Vec<AgentEvent>,
713        emit: &EmitFn,
714        turn_number: u32,
715    ) -> (Vec<Message>, Vec<AgentEvent>) {
716        let err_msg = format!("{}", e);
717        tracing::error!(session_id = ?self.session_id, "Unexpected streaming error: {}", err_msg);
718
719        let mut error_asst = oxicode_ai::AssistantMessage::new(
720            oxicode_ai::Api::OpenAiCompletions,
721            "agent",
722            &self.config.model_id,
723        );
724        error_asst.stop_reason = StopReason::Error;
725        error_asst
726            .content
727            .push(ContentBlock::Text(TextContent::new(format!(
728                "⚠ {}",
729                err_msg
730            ))));
731
732        new_messages.push(Message::Assistant(error_asst.clone()));
733        messages.push(Message::Assistant(error_asst.clone()));
734
735        emit(AgentEvent::MessageStart {
736            message: Message::Assistant(error_asst.clone()),
737        });
738        emit(AgentEvent::MessageEnd {
739            message: Message::Assistant(error_asst.clone()),
740        });
741        emit(AgentEvent::Error {
742            message: err_msg.clone(),
743            session_id: self.session_id.clone(),
744        });
745
746        emit(AgentEvent::TurnEnd {
747            turn_number,
748            assistant_message: Message::Assistant(error_asst.clone()),
749            tool_results: vec![],
750        });
751        events.push(AgentEvent::TurnEnd {
752            turn_number,
753            assistant_message: Message::Assistant(error_asst),
754            tool_results: vec![],
755        });
756        // Return Ok — lifecycle is complete
757        (messages.clone(), events.clone())
758    }
759
760    async fn run_loop(
761        &self,
762        initial_prompts: Vec<Message>,
763        emit: EmitFn,
764    ) -> Result<(Vec<Message>, Vec<AgentEvent>)> {
765        tracing::info!("[AGENT-LOOP] run_loop started");
766        let mut messages = self.state.get_state().messages.clone();
767        messages.extend(initial_prompts.clone());
768
769        let mut new_messages: Vec<Message> = initial_prompts;
770        let mut events = Vec::new();
771        let mut turn_number: u32 = 0;
772        let mut first_turn = true;
773
774        let mut pending_messages: Vec<Message> = self.drain_steering_queue();
775
776        // Append-only context for prefix-stable message management.
777        let mut append_only =
778            crate::agent_loop::append_only::AppendOnlyContext::new(messages.clone());
779
780        loop {
781            tracing::info!(
782                "[AGENT-LOOP] Top of loop, has_more_tool_calls={}, pending_messages={}",
783                true,
784                pending_messages.is_empty()
785            );
786            let mut has_more_tool_calls = true;
787
788            while has_more_tool_calls || !pending_messages.is_empty() {
789                if !first_turn {
790                    turn_number += 1;
791                    emit(AgentEvent::TurnStart { turn_number });
792                    events.push(AgentEvent::TurnStart { turn_number });
793                } else {
794                    first_turn = false;
795                    turn_number = 1;
796                    emit(AgentEvent::TurnStart { turn_number });
797                    events.push(AgentEvent::TurnStart { turn_number });
798                }
799
800                if !pending_messages.is_empty() {
801                    self.process_steering_messages(
802                        &mut pending_messages,
803                        &mut messages,
804                        &mut new_messages,
805                        &mut events,
806                        &emit,
807                    );
808                }
809
810                // Poll external hooks each turn to drain new steering/follow-up
811                // messages injected since the last turn.
812                self.poll_external_queues();
813
814                self.maybe_compact(&mut messages, turn_number as usize, &emit)
815                    .await;
816
817                // Keep the append-only context in sync with messages.
818                // After compaction, messages may have been replaced entirely.
819                append_only.sync_from(&messages);
820
821                tracing::info!("[AGENT-LOOP] About to call stream_assistant_response");
822                let ttsr = self.ttsr_engine.as_deref();
823                let outcome = stream_assistant_response(self, &mut messages, &emit, ttsr).await;
824
825                let assistant_message = match outcome {
826                    StreamOutcome::Complete(msg) => msg,
827                    StreamOutcome::Error {
828                        message: _message,
829                        detail,
830                    } => {
831                        // Check for message-ordering errors that can be recovered
832                        // by removing orphaned tool results.
833                        let is_tool_ordering_error = detail.contains("tool")
834                            && (detail.contains("must be a response")
835                                || detail.contains("preceding")
836                                || detail.contains("tool_calls"));
837
838                        if is_tool_ordering_error {
839                            let removed = sanitize_orphaned_tool_results(&mut messages);
840                            tracing::warn!(
841                                session_id = ?self.session_id,
842                                removed,
843                                detail = %detail,
844                                "Message-ordering error detected, removed orphaned tool results, retrying"
845                            );
846                            if removed > 0 {
847                                // Don't push the error message to history; retry the turn.
848                                emit(AgentEvent::Error {
849                                    message: format!(
850                                        "⚠ Provider rejected message order: {}. Removed {} orphaned tool results, retrying…",
851                                        detail, removed
852                                    ),
853                                    session_id: self.session_id.clone(),
854                                });
855                                continue; // Retry the turn with sanitized messages
856                            }
857                        }
858
859                        // Unrecoverable — fall through to error handler.
860                        return Ok(self
861                            .handle_streaming_error(
862                                anyhow::anyhow!("Provider stream error: {}", detail),
863                                &mut messages,
864                                &mut new_messages,
865                                &mut events,
866                                &emit,
867                                turn_number,
868                            )
869                            .await);
870                    }
871                    StreamOutcome::Cancelled(msg) => {
872                        emit(AgentEvent::TurnEnd {
873                            turn_number,
874                            assistant_message: Message::Assistant(msg.clone()),
875                            tool_results: vec![],
876                        });
877                        return Ok((messages, events));
878                    }
879                    StreamOutcome::RuleInterrupt { partial, rule } => {
880                        tracing::info!("RuleInterrupt: '{}' violated, retrying", rule.name);
881                        emit(AgentEvent::TtsrInterrupt {
882                            rule_name: rule.name.clone(),
883                            session_id: self.session_id.clone(),
884                        });
885                        messages.push(Message::Assistant(partial));
886                        // Render the interrupt message from the
887                        // shared `TTSR_INTERRUPT_TEMPLATE` so the
888                        // `prompts/ttsr-interrupt.md` file is the
889                        // single source of truth (no inline drift).
890                        let interrupt_body = TTSR_INTERRUPT_TEMPLATE
891                            .replace("{name}", &rule.name)
892                            .replace("{content}", &rule.content);
893                        messages.push(Message::user(interrupt_body));
894                        continue;
895                    }
896                };
897
898                new_messages.push(Message::Assistant(assistant_message.clone()));
899
900                if matches!(assistant_message.stop_reason, StopReason::Error) {
901                    if is_retryable_error(&assistant_message) {
902                        let did_retry =
903                            handle_retryable_error(self, &assistant_message, &mut messages, &emit)
904                                .await;
905                        if did_retry {
906                            emit(AgentEvent::TurnEnd {
907                                turn_number,
908                                assistant_message: Message::Assistant(assistant_message.clone()),
909                                tool_results: vec![],
910                            });
911                            events.push(AgentEvent::TurnEnd {
912                                turn_number,
913                                assistant_message: Message::Assistant(assistant_message.clone()),
914                                tool_results: vec![],
915                            });
916                            has_more_tool_calls = true;
917                            continue;
918                        }
919                    }
920
921                    emit(AgentEvent::TurnEnd {
922                        turn_number,
923                        assistant_message: Message::Assistant(assistant_message.clone()),
924                        tool_results: vec![],
925                    });
926                    events.push(AgentEvent::TurnEnd {
927                        turn_number,
928                        assistant_message: Message::Assistant(assistant_message.clone()),
929                        tool_results: vec![],
930                    });
931                    return Ok((messages, events));
932                }
933                if matches!(assistant_message.stop_reason, StopReason::Aborted) {
934                    if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
935                        emit(AgentEvent::AutoRetryEnd {
936                            success: true,
937                            attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
938                            final_error: None,
939                        });
940                        self.auto_retry_attempt.store(0, Ordering::Relaxed);
941                    }
942
943                    emit(AgentEvent::TurnEnd {
944                        turn_number,
945                        assistant_message: Message::Assistant(assistant_message.clone()),
946                        tool_results: vec![],
947                    });
948                    events.push(AgentEvent::TurnEnd {
949                        turn_number,
950                        assistant_message: Message::Assistant(assistant_message.clone()),
951                        tool_results: vec![],
952                    });
953                    return Ok((messages, events));
954                }
955
956                if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
957                    emit(AgentEvent::AutoRetryEnd {
958                        success: true,
959                        attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
960                        final_error: None,
961                    });
962                    self.auto_retry_attempt.store(0, Ordering::Relaxed);
963                }
964
965                let tool_calls = helpers::extract_tool_calls(&assistant_message);
966                tracing::info!(
967                    "[AGENT-LOOP] extract_tool_calls found {} calls, stop_reason={:?}",
968                    tool_calls.len(),
969                    assistant_message.stop_reason
970                );
971
972                let mut tool_results: Vec<oxicode_ai::ToolResultMessage> = Vec::new();
973                has_more_tool_calls = false;
974
975                if !tool_calls.is_empty() {
976                    tracing::info!("[AGENT-LOOP] Executing {} tool calls", tool_calls.len());
977                    let ctx = self.build_tool_context();
978                    let executed_batch = match execute_tool_calls(
979                        self,
980                        &mut messages,
981                        &assistant_message,
982                        tool_calls,
983                        &emit,
984                        &ctx,
985                    )
986                    .await
987                    {
988                        Ok(batch) => batch,
989                        Err(e) => {
990                            // Tool execution failed — emit TurnEnd and return Ok.
991                            // The lifecycle must always complete.
992                            tracing::error!(session_id = ?self.session_id, "Tool execution error: {}", e);
993                            emit(AgentEvent::Error {
994                                message: format!("Tool execution error: {}", e),
995                                session_id: self.session_id.clone(),
996                            });
997                            emit(AgentEvent::TurnEnd {
998                                turn_number,
999                                assistant_message: Message::Assistant(assistant_message.clone()),
1000                                tool_results: vec![],
1001                            });
1002                            events.push(AgentEvent::TurnEnd {
1003                                turn_number,
1004                                assistant_message: Message::Assistant(assistant_message.clone()),
1005                                tool_results: vec![],
1006                            });
1007                            return Ok((messages, events));
1008                        }
1009                    };
1010
1011                    tool_results = executed_batch.messages;
1012                    has_more_tool_calls = !executed_batch.terminate;
1013
1014                    if executed_batch.terminate {
1015                        tracing::warn!(
1016                            session_id = ?self.session_id,
1017                            "Tool batch terminated early (terminate flag set by after_tool_call hook). \
1018                             This halts the tool-calling loop. If this is unexpected, \
1019                             check after_tool_call hooks for unintended terminate: true."
1020                        );
1021                    }
1022
1023                    for result in &tool_results {
1024                        let result = self.maybe_truncate_tool_result(result.clone());
1025                        messages.push(Message::ToolResult(result.clone()));
1026                        new_messages.push(Message::ToolResult(result));
1027                    }
1028                    // Feed completed turn to the tool-call loop guard (omp pattern).
1029                    if has_more_tool_calls {
1030                        use oxicode_ai::utils::tool_call_loop::{
1031                            ToolCallLoopTurn, ToolCallRef, ToolResultRef,
1032                        };
1033                        let call_refs: Vec<ToolCallRef> = assistant_message
1034                            .content
1035                            .iter()
1036                            .filter_map(|block| match block {
1037                                oxicode_ai::ContentBlock::ToolCall(tc) => Some(ToolCallRef {
1038                                    id: tc.id.clone(),
1039                                    name: tc.name.clone(),
1040                                    arguments: tc.arguments.clone(),
1041                                }),
1042                                _ => None,
1043                            })
1044                            .collect();
1045                        let result_refs: Vec<ToolResultRef> = tool_results
1046                            .iter()
1047                            .map(|tr| {
1048                                let text: String = tr
1049                                    .content
1050                                    .iter()
1051                                    .filter_map(|b| match b {
1052                                        oxicode_ai::ContentBlock::Text(t) => Some(t.text.as_str()),
1053                                        _ => None,
1054                                    })
1055                                    .collect::<Vec<_>>()
1056                                    .join("\n");
1057                                ToolResultRef {
1058                                    tool_call_id: tr.tool_call_id.clone(),
1059                                    content: text,
1060                                }
1061                            })
1062                            .collect();
1063                        let turn = ToolCallLoopTurn {
1064                            tool_calls: &call_refs,
1065                            tool_results: &result_refs,
1066                        };
1067                        // NOTE: assign to a variable first so the MutexGuard
1068                        // from lock() is dropped at the semicolon. The if-let
1069                        // pattern would keep the guard alive inside the block,
1070                        // and calling reset() inside would deadlock on the
1071                        // non-reentrant parking_lot::Mutex.
1072                        let detection = self.tool_call_loop_guard.lock().record_turn(turn);
1073                        if let Some(detection) = detection {
1074                            let steering = format!(
1075                                "Tool-call loop detected: '{}' called {} consecutive \
1076                                 times with identical arguments. Result: '{}'. \
1077                                 Try a different approach.",
1078                                detection.tool_name, detection.count, detection.result_summary,
1079                            );
1080                            let msg = Message::User(oxicode_ai::UserMessage::new(steering));
1081                            messages.push(msg.clone());
1082                            new_messages.push(msg);
1083                            tracing::warn!(
1084                                session_id = ?self.session_id,
1085                                tool = %detection.tool_name,
1086                                count = detection.count,
1087                                "tool-call loop detected; injecting steering message"
1088                            );
1089                            self.tool_call_loop_guard.lock().reset();
1090                        }
1091                    }
1092                }
1093
1094                // ── Soft requirement check ──
1095                // After tool execution, check if all soft-required tools were called.
1096                // First miss → reminder steering message. Second miss → escalation.
1097                let assistant_has_tool_calls = assistant_message
1098                    .content
1099                    .iter()
1100                    .any(|b| matches!(b, oxicode_ai::ContentBlock::ToolCall(_)));
1101                if !self.config.soft_requirements.is_empty() && assistant_has_tool_calls {
1102                    let called_tools: std::collections::HashSet<String> = assistant_message
1103                        .content
1104                        .iter()
1105                        .filter_map(|block| match block {
1106                            oxicode_ai::ContentBlock::ToolCall(tc) => Some(tc.name.clone()),
1107                            _ => None,
1108                        })
1109                        .collect();
1110
1111                    for req in &self.config.soft_requirements {
1112                        if called_tools.contains(&req.tool_name) {
1113                            self.soft_requirement_state
1114                                .lock()
1115                                .reminded
1116                                .remove(&req.tool_name);
1117                            continue;
1118                        }
1119
1120                        if self
1121                            .soft_requirement_state
1122                            .lock()
1123                            .reminded
1124                            .contains(&req.tool_name)
1125                        {
1126                            tracing::warn!(
1127                                session_id = ?self.session_id,
1128                                tool = %req.tool_name,
1129                                "Soft requirement escalation"
1130                            );
1131                            emit(AgentEvent::SoftRequirementEscalation {
1132                                tool_name: req.tool_name.clone(),
1133                                reason: req.reason.clone(),
1134                                session_id: self.session_id.clone(),
1135                            });
1136                            let escalate_msg = Message::User(oxicode_ai::UserMessage::new(
1137                                format!(
1138                                    "[IMPORTANT] You still have not used the `{}` tool, which is required. {}",
1139                                    req.tool_name, req.reason,
1140                                ),
1141                            ));
1142                            messages.push(escalate_msg.clone());
1143                            new_messages.push(escalate_msg);
1144                        } else {
1145                            tracing::info!(
1146                                session_id = ?self.session_id,
1147                                tool = %req.tool_name,
1148                                "Soft requirement reminder"
1149                            );
1150                            self.soft_requirement_state
1151                                .lock()
1152                                .reminded
1153                                .insert(req.tool_name.clone());
1154                            emit(AgentEvent::SoftRequirementReminder {
1155                                tool_name: req.tool_name.clone(),
1156                                reason: req.reason.clone(),
1157                                session_id: self.session_id.clone(),
1158                            });
1159                            let reminder_msg =
1160                                Message::User(oxicode_ai::UserMessage::new(format!(
1161                                    "Reminder: please use the `{}` tool. {}",
1162                                    req.tool_name, req.reason,
1163                                )));
1164                            messages.push(reminder_msg.clone());
1165                            new_messages.push(reminder_msg);
1166                        }
1167                    }
1168                }
1169
1170                emit(AgentEvent::TurnEnd {
1171                    turn_number,
1172                    assistant_message: Message::Assistant(assistant_message.clone()),
1173                    tool_results: tool_results.clone(),
1174                });
1175                events.push(AgentEvent::TurnEnd {
1176                    turn_number,
1177                    assistant_message: Message::Assistant(assistant_message.clone()),
1178                    tool_results: tool_results.clone(),
1179                });
1180
1181                if should_stop_after_turn(&self.external_stop) {
1182                    tracing::info!("[AGENT-LOOP] external_stop, ending loop");
1183                    return Ok((messages, events));
1184                }
1185
1186                pending_messages = self.drain_steering_queue();
1187                tracing::info!(
1188                    "[AGENT-LOOP] TurnEnd complete, pending_messages={}, has_more_tool_calls={}",
1189                    !pending_messages.is_empty(),
1190                    has_more_tool_calls
1191                );
1192
1193                // Early stop check: if external_stop was set (e.g. Ctrl+C),
1194                // don't process steering messages from the next turn.
1195                if self.external_stop.load(Ordering::SeqCst) {
1196                    tracing::info!(
1197                        "[AGENT-LOOP] external_stop set after steering drain, ending loop"
1198                    );
1199                    return Ok((messages, events));
1200                }
1201            }
1202
1203            // Re-check steering queue after the inner while loop exits.
1204            // This closes the race window where steer() is called between the
1205            // last drain_steering_queue() and the while-exit condition check.
1206            let late_steering = self.drain_steering_queue();
1207            if !late_steering.is_empty() {
1208                tracing::info!(
1209                    count = late_steering.len(),
1210                    "[AGENT-LOOP] Caught late steering messages after inner loop exit"
1211                );
1212                pending_messages = late_steering;
1213                continue;
1214            }
1215
1216            let follow_up_messages = self.drain_follow_up_queue();
1217            if !follow_up_messages.is_empty() {
1218                pending_messages = follow_up_messages;
1219                continue;
1220            }
1221
1222            // Final check: one more steering drain after follow-up to catch
1223            // messages injected during the follow-up drain window.
1224            let final_steering = self.drain_steering_queue();
1225            if !final_steering.is_empty() {
1226                pending_messages = final_steering;
1227                continue;
1228            }
1229
1230            break;
1231        }
1232
1233        // Final sync: keep append-only context consistent.
1234        append_only.sync_from(&messages);
1235
1236        Ok((messages, events))
1237    }
1238
1239    /// Build the compaction instruction, appending injected TTSR rule
1240    /// names so that the model remembers rules already enforced.
1241    fn build_compaction_instruction(&self) -> Option<String> {
1242        let base = self.config.compaction_instruction.as_deref();
1243        let injected = self
1244            .ttsr_engine
1245            .as_ref()
1246            .map(|e| e.injected_records())
1247            .unwrap_or_default();
1248        if injected.is_empty() {
1249            return base.map(|s| s.to_string());
1250        }
1251        let mut instr = base.map(|s| s.to_string()).unwrap_or_default();
1252        instr.push_str("\n\nThe following rules have already been enforced in this session and corrections applied. Do NOT violate them again:");
1253        for (name, _turn) in &injected {
1254            instr.push_str(&format!("\n- {name}"));
1255        }
1256        Some(instr)
1257    }
1258
1259    async fn maybe_compact(&self, messages: &mut Vec<Message>, iteration: usize, emit: &EmitFn) {
1260        // Decide the context-size value to drive compaction with. Prefer
1261        // the provider-reported `last_input_tokens` (ground truth) over
1262        // the legacy `bytes/4` heuristic. The heuristic can undercount
1263        // by 3-4× on token-dense content (base64, JSON, CJK) and is the
1264        // reason `CompactionStrategy::Threshold` was effectively a no-op
1265        // in issue #28's failure (35k estimated vs 122k actual).
1266        //
1267        // The provider count lags by exactly one turn: `maybe_compact`
1268        // runs at the **top** of a turn, before streaming. So the
1269        // `last_input_tokens` we read here reflects the *previous*
1270        // turn's `Done` event. The drift is at most the size of the
1271        // tool results the model is about to receive, which is small
1272        // relative to the failure-mode drift the heuristic suffers.
1273        // For turn 1 there is no prior count, so we fall back to the
1274        // heuristic on cold start.
1275        let snapshot = self.state.get_state();
1276        let (context_tokens, source_label) = match snapshot.current_token_source() {
1277            TokenSource::Real(n) => (n, "provider-reported"),
1278            TokenSource::Heuristic(n) => (n, "bytes/4 heuristic (cold start)"),
1279            TokenSource::None => (0, "empty"),
1280        };
1281        // Surface heuristic drift as a warning when the operator has
1282        // observed at least one provider count and it diverges from
1283        // the estimate by more than 2×. This is the diagnostic path
1284        // from #28's "Proposed fix" option 3.
1285        if let Some(div) = snapshot.last_estimate_divergence
1286            && div > 2.0
1287        {
1288            tracing::warn!(
1289                session_id = ?self.session_id,
1290                divergence = div,
1291                reported = snapshot.last_input_tokens.unwrap_or(0),
1292                estimate = snapshot.last_estimate_at_report.unwrap_or(0),
1293                "Token-count heuristic (bytes/4) diverges from provider-reported usage \
1294                 by >2x; CompactionStrategy::Threshold decisions are using the \
1295                 provider-reported count (issue #28 gap 2)."
1296            );
1297        }
1298        drop(snapshot);
1299
1300        if !self
1301            .compaction_manager
1302            .should_compact(context_tokens, iteration)
1303        {
1304            return;
1305        }
1306
1307        // ── Mechanical shake (LLM-free) ────────────────────────────
1308        // Try eliding large tool results / code blocks BEFORE invoking
1309        // the LLM compactor. If enough tokens are recovered, skip the
1310        // expensive LLM round-trip entirely.
1311        let shake_config = compaction::shake::ShakeConfig::default();
1312        match compaction::shake::shake(messages, &shake_config) {
1313            compaction::shake::ShakeOutcome::Shaken {
1314                regions_elided,
1315                tokens_saved,
1316            } => {
1317                tracing::info!(
1318                    session_id = ?self.session_id,
1319                    regions_elided,
1320                    tokens_saved,
1321                    "Shake compaction recovered {} tokens ({} regions), skipping LLM compaction",
1322                    tokens_saved,
1323                    regions_elided
1324                );
1325                emit(AgentEvent::Compaction {
1326                    event: CompactionEvent::Triggered {
1327                        context_tokens,
1328                        iteration,
1329                        source: format!(
1330                            "shake ({} tokens, {} regions)",
1331                            tokens_saved, regions_elided
1332                        ),
1333                    },
1334                });
1335                return; // shake recovered enough — no LLM compaction needed
1336            }
1337            compaction::shake::ShakeOutcome::NoChange => {
1338                // Not enough elidable content — fall through to LLM compaction.
1339            }
1340        }
1341
1342        emit(AgentEvent::Compaction {
1343            event: CompactionEvent::Triggered {
1344                context_tokens,
1345                iteration,
1346                source: source_label.to_string(),
1347            },
1348        });
1349
1350        let messages_to_compact: Vec<Message> = messages.to_vec();
1351        let instruction = self.build_compaction_instruction();
1352
1353        match self
1354            .compaction_manager
1355            .compact_if_needed(
1356                &messages_to_compact,
1357                instruction.as_deref(),
1358                context_tokens,
1359                iteration,
1360            )
1361            .await
1362        {
1363            Ok(Some(compacted)) => {
1364                let start = Instant::now();
1365                let message_count = compacted.compacted_count;
1366
1367                emit(AgentEvent::Compaction {
1368                    event: CompactionEvent::Started { message_count },
1369                });
1370
1371                let kept_messages = compacted.kept_messages;
1372                let summary = compacted.summary;
1373                let compacted_count = compacted.compacted_count;
1374
1375                *messages = kept_messages;
1376
1377                let state_msgs = messages.clone();
1378                self.state.update(|s| {
1379                    s.replace_messages(state_msgs);
1380                });
1381
1382                let compacted_ctx = CompactedContext {
1383                    summary,
1384                    kept_messages: Vec::new(),
1385                    compacted_count,
1386                };
1387                emit(AgentEvent::Compaction {
1388                    event: CompactionEvent::Completed {
1389                        result: compacted_ctx.clone(),
1390                        duration_ms: start.elapsed().as_millis() as u64,
1391                    },
1392                });
1393
1394                // Async compaction hook — awaited, not fire-and-forget.
1395                if let Some(ref hook) = self.config.on_compaction {
1396                    match hook(compacted_ctx).await {
1397                        Ok(()) => {
1398                            tracing::debug!("Compaction hook completed successfully");
1399                        }
1400                        Err(e) => {
1401                            tracing::warn!(error = %e, "Compaction hook failed");
1402                        }
1403                    }
1404                }
1405            }
1406            Ok(None) => {}
1407            Err(e) => {
1408                emit(AgentEvent::Compaction {
1409                    event: CompactionEvent::Failed {
1410                        error: e.to_string(),
1411                    },
1412                });
1413            }
1414        }
1415    }
1416
1417    fn resolve_model(&self) -> Result<oxicode_ai::Model> {
1418        self.resolver
1419            .resolve_model(&self.config.model_id)
1420            .ok_or_else(|| Error::msg(format!("Model not found: {}", self.config.model_id)))
1421    }
1422}
1423
1424#[cfg(test)]
1425mod session_id_wiring_tests {
1426    //! Regression coverage for the #13 fix.
1427    //! `build_tool_context` is private; testing it here keeps the test in the
1428    //! same module so it can reach private surface. We never stream — the
1429    //! nop provider only exists to satisfy `AgentLoop::new_with_resolver`.
1430    use super::*;
1431    use crate::ProviderResolver;
1432    use crate::agent_loop::config::AgentLoopConfig;
1433    use crate::config::ToolExecutionMode;
1434    use crate::state::SharedState;
1435    use crate::tools::ToolRegistry;
1436    use oxicode_ai::{
1437        CompactionStrategy, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1438    };
1439    use std::future::Future;
1440    use std::pin::Pin;
1441
1442    struct NopProvider;
1443    impl Provider for NopProvider {
1444        fn stream<'a>(
1445            &'a self,
1446            _model: &'a Model,
1447            _context: &'a Context,
1448            _options: Option<StreamOptions>,
1449        ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1450            Box::pin(async {
1451                Err(ProviderError::NotImplemented(
1452                    "session-id wiring tests never stream".to_string(),
1453                ))
1454            })
1455        }
1456    }
1457
1458    struct NullResolver;
1459    impl ProviderResolver for NullResolver {
1460        fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1461            None
1462        }
1463        fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1464            None
1465        }
1466    }
1467
1468    fn loop_with(session_id: Option<String>) -> AgentLoop {
1469        let config = AgentLoopConfig {
1470            model_id: "test/model".to_string(),
1471            system_prompt: None,
1472            temperature: 1.0,
1473            max_tokens: 4096,
1474            tool_execution: ToolExecutionMode::Sequential,
1475            compaction_strategy: CompactionStrategy::Disabled,
1476            compaction_instruction: None,
1477            context_window: 128_000,
1478            session_id,
1479            transport: None,
1480            compact_on_start: false,
1481            max_retry_delay_ms: None,
1482            auto_retry_enabled: true,
1483            auto_retry_max_attempts: 3,
1484            auto_retry_base_delay_ms: 1000,
1485            workspace_dir: None,
1486            provider_options: None,
1487            on_compaction: None,
1488            snapshot_store: None,
1489            memory: None,
1490            url_resolver: None,
1491            todo: None,
1492            agent_pool: None,
1493            lsp: None,
1494            ttsr_engine: None,
1495            subagent_runner: None,
1496            subagent_depth: 0,
1497            max_tool_result_bytes: None,
1498            thinking_loop_detection: false, // disable for unit tests
1499            ..Default::default()
1500        };
1501        AgentLoop::new_with_resolver(
1502            Arc::new(NopProvider),
1503            config,
1504            Arc::new(ToolRegistry::new()),
1505            SharedState::new(),
1506            Arc::new(NullResolver),
1507        )
1508    }
1509
1510    /// Regression for defect #13: `AgentLoopConfig.session_id` MUST flow into
1511    /// `ToolContext.session_id`. Before the fix, the field was hardcoded to
1512    /// `None`, so the `issue` tool received an empty caller id and bypassed
1513    /// all ownership/liveness checks (two agents could both `start` the same
1514    /// issue and the last writer silently won).
1515    #[test]
1516    fn tool_context_inherits_session_id_when_set() {
1517        let loop_ = loop_with(Some("proc-test-session-id".to_string()));
1518        let ctx = loop_.build_tool_context();
1519        assert_eq!(
1520            ctx.session_id.as_deref(),
1521            Some("proc-test-session-id"),
1522            "ToolContext.session_id must inherit AgentConfig.session_id"
1523        );
1524    }
1525
1526    #[test]
1527    fn tool_context_session_id_defaults_to_none() {
1528        let loop_ = loop_with(None);
1529        let ctx = loop_.build_tool_context();
1530        assert!(
1531            ctx.session_id.is_none(),
1532            "default ToolContext.session_id should be None"
1533        );
1534    }
1535}
1536
1537// ── Gap 1: tool-result truncation tests (issue #28) ──────────────────
1538
1539#[cfg(test)]
1540mod truncation_tests {
1541    use super::*;
1542    use crate::agent::ProviderResolver;
1543    use oxicode_ai::{
1544        ContentBlock, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1545        TextContent, ToolResultMessage,
1546    };
1547    use std::future::Future;
1548    use std::pin::Pin;
1549
1550    struct NopProvider;
1551    impl Provider for NopProvider {
1552        fn stream<'a>(
1553            &'a self,
1554            _model: &'a Model,
1555            _context: &'a Context,
1556            _options: Option<StreamOptions>,
1557        ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1558            Box::pin(async {
1559                Err(ProviderError::NotImplemented(
1560                    "truncation tests never stream".to_string(),
1561                ))
1562            })
1563        }
1564    }
1565
1566    struct NullResolver;
1567    impl ProviderResolver for NullResolver {
1568        fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1569            None
1570        }
1571        fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1572            None
1573        }
1574    }
1575
1576    fn make_result(text: &str) -> ToolResultMessage {
1577        ToolResultMessage::new(
1578            "tc_test".to_string(),
1579            "test_tool",
1580            vec![ContentBlock::Text(TextContent::new(text.to_string()))],
1581        )
1582    }
1583
1584    fn loop_with_limit(limit: Option<usize>) -> AgentLoop {
1585        let config = AgentLoopConfig {
1586            model_id: "test/model".to_string(),
1587            max_tool_result_bytes: limit,
1588            ..Default::default()
1589        };
1590        AgentLoop::new_with_resolver(
1591            Arc::new(NopProvider),
1592            config,
1593            Arc::new(ToolRegistry::new()),
1594            SharedState::new(),
1595            Arc::new(NullResolver),
1596        )
1597    }
1598
1599    #[test]
1600    fn truncate_passthrough_when_none() {
1601        let loop_ = loop_with_limit(None);
1602        let result = make_result(&"x".repeat(10_000));
1603        let truncated = loop_.maybe_truncate_tool_result(result);
1604        if let ContentBlock::Text(tc) = &truncated.content[0] {
1605            assert_eq!(tc.text.len(), 10_000);
1606            assert!(!tc.text.contains("truncated"));
1607        }
1608    }
1609
1610    #[test]
1611    fn truncate_passthrough_when_under_limit() {
1612        let loop_ = loop_with_limit(Some(1000));
1613        let result = make_result(&"x".repeat(500));
1614        let truncated = loop_.maybe_truncate_tool_result(result);
1615        if let ContentBlock::Text(tc) = &truncated.content[0] {
1616            assert_eq!(tc.text.len(), 500);
1617            assert!(!tc.text.contains("truncated"));
1618        }
1619    }
1620
1621    #[test]
1622    fn truncate_applies_when_over_limit() {
1623        let loop_ = loop_with_limit(Some(100));
1624        let result = make_result(&"x".repeat(500));
1625        let truncated = loop_.maybe_truncate_tool_result(result);
1626        if let ContentBlock::Text(tc) = &truncated.content[0] {
1627            assert!(
1628                tc.text.len() < 500,
1629                "text not truncated: {} bytes",
1630                tc.text.len()
1631            );
1632            assert!(tc.text.contains("truncated"), "missing truncation marker");
1633            assert!(tc.text.contains("400 bytes omitted"));
1634        }
1635    }
1636}