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