Skip to main content

oxi_agent/agent_loop/
mod.rs

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