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