Skip to main content

oxi_agent/
agent.rs

1/// Core agent implementation
2use crate::config::AgentConfig;
3use crate::config::ShouldStopAfterTurnContext;
4use crate::events::AgentEvent;
5use crate::state::{AgentState, SharedState};
6use crate::tools::{AgentTool, ToolRegistry};
7use crate::types::{Response, StopReason};
8use anyhow::{Error, Result};
9use oxi_ai::{
10    CompactionManager, CompactionStrategy, LlmCompactor, Model, Provider, transform_for_provider,
11};
12use parking_lot::RwLock;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16// ── ProviderResolver trait ────────────────────────────────────────
17
18/// Trait for resolving providers and models within an Agent.
19///
20/// This abstracts away global static registries, allowing SDK users
21/// to provide isolated provider/model lookups.
22///
23/// When using the SDK (`oxi-sdk`), the `Oxi` engine implements this trait.
24/// When using `Agent::new()` directly, a global fallback is used.
25pub trait ProviderResolver: Send + Sync + 'static {
26    /// Resolve a provider by name, returning an Arc handle.
27    fn resolve_provider(&self, name: &str) -> Option<Arc<dyn Provider>>;
28
29    /// Resolve a model ID ("provider/model" or bare "model") to a Model.
30    fn resolve_model(&self, model_id: &str) -> Option<Model>;
31}
32
33/// Global provider resolver — uses `oxi_ai` global functions.
34///
35/// This is the default resolver when using `Agent::new()`, preserving
36/// backward compatibility with existing CLI usage.
37pub(crate) struct GlobalProviderResolver;
38
39impl ProviderResolver for GlobalProviderResolver {
40    fn resolve_provider(&self, name: &str) -> Option<Arc<dyn Provider>> {
41        oxi_ai::get_provider(name).map(Arc::from)
42    }
43
44    fn resolve_model(&self, model_id: &str) -> Option<Model> {
45        crate::model_id::resolve_model_from_id(model_id)
46    }
47}
48
49// ── AgentInner ────────────────────────────────────────────────────
50/// Mutable agent internals protected by a read-write lock.
51struct AgentInner {
52    config: AgentConfig,
53    provider: Arc<dyn Provider>,
54    /// Side-dispatch closures invoked for every `AgentEvent` emitted by
55    /// the agent run methods. Used by `oxi-sdk` to bridge observability
56    /// types (Tracer, CostTracker, ...) into the agent loop without
57    /// leaking SDK types into `oxi-agent`.
58    ///
59    /// Lock-mutex rather than `RwLock`: dispatch lists mutate rarely
60    /// (only on `add_observability_dispatch`), but reads happen on every
61    /// event (high frequency), so a `Mutex` with cheap poison-free
62    /// acquisition is the right shape.
63    observability_dispatch: parking_lot::Mutex<Vec<EventDispatchFn>>,
64}
65
66/// Type alias for an observability dispatch handler. Each entry is a
67/// closure registered via [`Agent::add_observability_dispatch`] and
68/// invoked on every emitted `AgentEvent`. Named to keep the
69/// [`AgentInner`] field readable without an inline `dyn` route.
70type EventDispatchFn = Arc<dyn Fn(AgentEvent) + Send + Sync>;
71
72impl Clone for AgentInner {
73    fn clone(&self) -> Self {
74        Self {
75            config: self.config.clone(),
76            provider: Arc::clone(&self.provider),
77            // The dispatch list is *not* cloned: each `Agent` instance has
78            // its own observers. Cloning the AgentInner (rare; happens in
79            // `run_with_channel_inner` when sharing config across loops)
80            // gives the new loop an empty observer set, which is correct:
81            // the *Agent* retains the original dispatch list, and the
82            // temporary inner clone is discarded after the run.
83            observability_dispatch: parking_lot::Mutex::new(Vec::new()),
84        }
85    }
86}
87///
88/// Manages provider, tool registry, state, and compaction, providing an
89/// agentic loop for prompt execution, model switching, tool calls, and fallback.
90///
91/// Supports session continuation via [`continue_with`] and tokio-native
92/// event streaming via [`run_tokio_stream`].
93///
94/// [`continue_with`]: Agent::continue_with
95/// [`run_tokio_stream`]: Agent::run_tokio_stream
96/// Deferred model switch request, stored when the agent is running.
97struct PendingModelSwitch {
98    model_id: String,
99    provider: Arc<dyn Provider>,
100    /// Whether messages need cross-provider transformation.
101    needs_transform: bool,
102    old_api: oxi_ai::Api,
103    new_api: oxi_ai::Api,
104}
105
106/// Agent runtime.
107///
108/// Manages provider, tool registry, state, and compaction, providing an
109/// agentic loop for prompt execution, model switching, tool calls, and fallback.
110///
111/// Supports session continuation, tokio-native event streaming, and deferred
112/// model switching (changes are queued while a loop is running and applied
113/// after it completes).
114#[allow(missing_docs)]
115pub struct Agent {
116    inner: RwLock<AgentInner>,
117    tools: Arc<ToolRegistry>,
118    state: SharedState,
119    compaction_manager: CompactionManager,
120    hooks: parking_lot::RwLock<crate::config::AgentHooks>,
121    /// Guard: true while a run is in progress. Prevents concurrent runs.
122    is_running: Arc<AtomicBool>,
123    /// Provider/model resolver. Uses global functions by default,
124    /// or a custom resolver when created via `new_with_resolver()`.
125    resolver: Arc<dyn ProviderResolver>,
126    /// Shared cancellation flag. Set by `cancel()` (e.g. on Ctrl+C),
127    /// propagated to AgentLoop's `external_stop` during each run.
128    cancel_flag: Arc<AtomicBool>,
129    /// Shared auto-retry enabled flag — runtime-toggleable via `set_auto_retry`,
130    /// injected into each ephemeral AgentLoop via `set_auto_retry_state`.
131    auto_retry_enabled: Arc<AtomicBool>,
132    /// Shared auto-retry cancel flag (RPC `abort_retry`).
133    auto_retry_cancel: Arc<AtomicBool>,
134    /// Shared auto-retry notify for immediate retry-sleep wake-up.
135    auto_retry_notify: Arc<tokio::sync::Notify>,
136    /// Pending model switch — stored when the agent is running,
137    /// applied after the current loop completes.
138    pending_model_switch: RwLock<Option<PendingModelSwitch>>,
139}
140
141impl Agent {
142    /// Create a new agent with the given provider, config, and tool registry.
143    ///
144    /// Uses the global `oxi_ai::get_provider()` / `resolve_model_from_id()`
145    /// for model switching. For isolated instances, use [`new_with_resolver`].
146    ///
147    /// [`new_with_resolver`]: Agent::new_with_resolver
148    pub fn new(provider: Arc<dyn Provider>, config: AgentConfig, tools: Arc<ToolRegistry>) -> Self {
149        let resolver = Arc::new(GlobalProviderResolver);
150        Self::build_inner(provider, config, tools, resolver)
151    }
152
153    /// Create an agent with a custom provider/model resolver.
154    ///
155    /// This is the preferred constructor for SDK usage where provider
156    /// and model registries must be isolated from global state.
157    pub fn new_with_resolver(
158        provider: Arc<dyn Provider>,
159        config: AgentConfig,
160        tools: Arc<ToolRegistry>,
161        resolver: Arc<dyn ProviderResolver>,
162    ) -> Self {
163        Self::build_inner(provider, config, tools, resolver)
164    }
165
166    /// Create an agent with an empty tool registry.
167    pub fn new_empty(provider: Arc<dyn Provider>, config: AgentConfig) -> Self {
168        Self::new(provider, config, Arc::new(ToolRegistry::new()))
169    }
170
171    /// Get the agent configuration (read guard)
172    fn config(&self) -> parking_lot::RwLockReadGuard<'_, AgentInner> {
173        self.inner.read()
174    }
175
176    /// Get a write guard for the agent inner state
177    fn inner_mut(&self) -> parking_lot::RwLockWriteGuard<'_, AgentInner> {
178        self.inner.write()
179    }
180
181    /// Get the current model ID
182    pub fn model_id(&self) -> String {
183        self.config().config.model_id.clone()
184    }
185
186    /// Get the agent configuration (full clone)
187    pub fn get_config(&self) -> AgentConfig {
188        self.config().config.clone()
189    }
190
191    /// Internal constructor shared by `new()` and `new_with_resolver()`.
192    fn build_inner(
193        provider: Arc<dyn Provider>,
194        config: AgentConfig,
195        tools: Arc<ToolRegistry>,
196        resolver: Arc<dyn ProviderResolver>,
197    ) -> Self {
198        let mut compaction_manager =
199            CompactionManager::new(config.compaction_strategy.clone(), config.context_window);
200
201        // Pre-initialize the LLM compactor if compaction is enabled
202        if config.compaction_strategy != CompactionStrategy::Disabled {
203            let model = resolver.resolve_model(&config.model_id);
204
205            if let Some(model) = model {
206                let llm_compactor =
207                    Arc::new(LlmCompactor::new(model.clone(), Arc::clone(&provider)));
208                compaction_manager.set_compactor(llm_compactor);
209            }
210        }
211
212        Self {
213            inner: RwLock::new(AgentInner {
214                config,
215                provider,
216                observability_dispatch: parking_lot::Mutex::new(Vec::new()),
217            }),
218            tools,
219            state: SharedState::new(),
220            compaction_manager,
221            hooks: parking_lot::RwLock::new(crate::config::AgentHooks::default()),
222            is_running: Arc::new(AtomicBool::new(false)),
223            resolver,
224            cancel_flag: Arc::new(AtomicBool::new(false)),
225            auto_retry_enabled: Arc::new(AtomicBool::new(true)),
226            auto_retry_cancel: Arc::new(AtomicBool::new(false)),
227            auto_retry_notify: Arc::new(tokio::sync::Notify::new()),
228            pending_model_switch: RwLock::new(None),
229        }
230    }
231
232    /// Get a reference to the provider resolver.
233    pub fn resolver(&self) -> &Arc<dyn ProviderResolver> {
234        &self.resolver
235    }
236
237    /// Switch the model used for future LLM calls.
238    ///
239    /// Switch model mid-conversation.
240    ///
241    /// If the agent is currently running, the switch is deferred: the new
242    /// model and provider are stored in `pending_model_switch` and applied
243    /// automatically when the current loop finishes. This ensures the
244    /// running loop completes with a consistent provider/model without
245    /// interruption.
246    ///
247    /// If the agent is idle, the switch takes effect immediately.
248    ///
249    /// If the new model uses a different provider API, the conversation
250    /// history is automatically transformed for cross-provider compatibility
251    /// (e.g. thinking blocks are converted to `<thinking>` tags).
252    ///
253    /// # Arguments
254    /// * `model_id` - New model ID in `provider/model` format
255    ///
256    /// # Returns
257    /// `Ok(())` on success, or an error if the model/provider is unknown
258    ///
259    /// # Credentials
260    /// The new provider is constructed via [`ProviderResolver::resolve_provider`],
261    /// which is the single credential authority — the wired `AuthProvider`
262    /// port (sync fast-path) supplies the API key. The old `api_key` parameter
263    /// was removed in 0.55.0; see issues #39 and #40.
264    pub fn switch_model(&self, model_id: &str) -> Result<()> {
265        let new_model = self
266            .resolver
267            .resolve_model(model_id)
268            .ok_or_else(|| Error::msg(format!("Model '{}' not found", model_id)))?;
269
270        // Create the new provider via resolver
271        let new_provider = self
272            .resolver
273            .resolve_provider(&new_model.provider)
274            .ok_or_else(|| Error::msg(format!("Provider '{}' not found", new_model.provider)))?;
275
276        // Detect API change
277        let (old_api, needs_transform) = {
278            let inner = self.config();
279            let old_api = self
280                .resolver
281                .resolve_model(&inner.config.model_id)
282                .map(|m| m.api)
283                .unwrap_or(oxi_ai::Api::AnthropicMessages);
284            (old_api, old_api != new_model.api)
285        };
286
287        // If the agent is currently running, defer the switch.
288        if self.is_running.load(Ordering::SeqCst) {
289            tracing::info!(
290                "[AGENT] Agent running, deferring model switch to '{}' until loop completes",
291                model_id
292            );
293            *self.pending_model_switch.write() = Some(PendingModelSwitch {
294                model_id: model_id.to_string(),
295                provider: new_provider,
296                needs_transform,
297                old_api,
298                new_api: new_model.api,
299            });
300            // Update config immediately so model_id() returns the new value,
301            // but leave provider unchanged so the running loop keeps its provider.
302            {
303                let mut inner = self.inner_mut();
304                inner.config.model_id = model_id.to_string();
305            }
306            return Ok(());
307        }
308
309        // Agent is idle — apply immediately.
310        if needs_transform {
311            let messages = self.state.get_state().messages.clone();
312            let transformed = transform_for_provider(&messages, &old_api, &new_model.api);
313            self.state.update(|s| {
314                s.replace_messages(transformed);
315            });
316        }
317
318        let mut inner = self.inner_mut();
319        inner.config.model_id = model_id.to_string();
320        inner.provider = new_provider;
321
322        Ok(())
323    }
324
325    /// Switch the model using a pre-resolved `Model` object.
326    ///
327    /// This is useful when the caller has already looked up the model
328    /// and optionally created the provider.
329    ///
330    /// Like [`switch_model`], if the agent is currently running, the switch
331    /// is deferred until the current loop completes.
332    ///
333    /// # Credentials
334    /// The new provider is constructed via [`ProviderResolver::resolve_provider`],
335    /// the single credential authority (sync `AuthProvider` fast-path).
336    /// The old `api_key` parameter was removed in 0.55.0; see issues #39/#40.
337    ///
338    /// [`switch_model`]: Agent::switch_model
339    pub fn switch_to_model(&self, model: &oxi_ai::Model) -> Result<()> {
340        let model_id = format!("{}/{}", model.provider, model.id);
341        let new_provider = self
342            .resolver
343            .resolve_provider(&model.provider)
344            .ok_or_else(|| Error::msg(format!("Provider '{}' not found", model.provider)))?;
345
346        // Detect API change
347        let (old_api, needs_transform) = {
348            let inner = self.config();
349            let old_api = self
350                .resolver
351                .resolve_model(&inner.config.model_id)
352                .map(|m| m.api)
353                .unwrap_or(oxi_ai::Api::AnthropicMessages);
354            (old_api, old_api != model.api)
355        };
356
357        // If the agent is currently running, defer the switch.
358        if self.is_running.load(Ordering::SeqCst) {
359            tracing::info!(
360                "[AGENT] Agent running, deferring model switch to '{}' until loop completes",
361                model_id
362            );
363            *self.pending_model_switch.write() = Some(PendingModelSwitch {
364                model_id: model_id.clone(),
365                provider: new_provider,
366                needs_transform,
367                old_api,
368                new_api: model.api,
369            });
370            let mut inner = self.inner_mut();
371            inner.config.model_id = model_id;
372            return Ok(());
373        }
374
375        // Agent is idle — apply immediately.
376        if needs_transform {
377            let messages = self.state.get_state().messages.clone();
378            let transformed = transform_for_provider(&messages, &old_api, &model.api);
379            self.state.update(|s| {
380                s.replace_messages(transformed);
381            });
382        }
383
384        let mut inner = self.inner_mut();
385        inner.config.model_id = model_id;
386        inner.provider = new_provider;
387
388        Ok(())
389    }
390
391    /// Refresh credentials by re-resolving the current provider via the resolver.
392    ///
393    /// After the resolver-centric credential model (0.55.0), the provider
394    /// instance is the single source of truth for API keys. To pick up
395    /// credential changes — e.g. the user updated their auth store via the
396    /// TUI overlay — call this to re-resolve the current provider and swap
397    /// it in. The resolver consults the wired `AuthProvider` port on every
398    /// call, so updates are reflected without rebuilding the engine.
399    ///
400    /// Returns `Ok(())` if a fresh provider was resolved and swapped, or an
401    /// error if the resolver could not produce a provider (the existing
402    /// provider is left untouched on error). Replaces the deprecated
403    /// `refresh_api_key(&self, api_key)` from pre-0.55.0; see issues #39/#40.
404    pub fn refresh_credentials(&self) -> Result<()> {
405        let provider_name = {
406            let inner = self.config();
407            inner.config.model_id.split('/').next().map(str::to_string)
408        };
409        let name = provider_name.as_deref().unwrap_or("anthropic");
410        let new_provider = self
411            .resolver
412            .resolve_provider(name)
413            .ok_or_else(|| Error::msg(format!("Provider '{}' not found", name)))?;
414        let mut inner = self.inner_mut();
415        inner.provider = new_provider;
416        Ok(())
417    }
418
419    /// Get a handle to the tool registry.
420    pub fn tools(&self) -> Arc<ToolRegistry> {
421        Arc::clone(&self.tools)
422    }
423
424    /// Get a snapshot of the current agent state.
425    pub fn state(&self) -> AgentState {
426        self.state.get_state()
427    }
428
429    /// Update agent state in-place. Used by compaction to replace messages.
430    pub fn update_state(&self, f: impl FnOnce(&mut AgentState)) {
431        self.state.update(f);
432    }
433
434    /// Reset agent state for a new conversation
435    pub fn reset(&self) {
436        self.state.reset();
437    }
438
439    /// Register a tool that the agent can invoke during a run.
440    pub fn add_tool<T: AgentTool + 'static>(&self, tool: T) {
441        self.tools.register(tool);
442    }
443
444    /// Update the system prompt for future interactions.
445    pub fn set_system_prompt(&self, prompt: String) {
446        self.inner_mut().config.system_prompt = Some(prompt);
447    }
448
449    /// Get the compaction manager
450    pub fn compaction_manager(&self) -> &CompactionManager {
451        &self.compaction_manager
452    }
453    /// Update the compaction strategy for future runs.
454    ///
455    /// The strategy is read fresh from the config at the start of each run
456    /// (see `run_with_channel_inner`), so this takes effect on the next
457    /// agent turn — never mid-run. Pair with `compaction_manager()` for
458    /// manual compaction, which is unaffected by the strategy.
459    pub fn set_compaction_strategy(&self, strategy: oxi_ai::CompactionStrategy) {
460        self.inner.write().config.compaction_strategy = strategy;
461    }
462    /// Get the compaction strategy that will be used on the next run.
463    ///
464    /// This reads from `inner.config` (mutable via `set_compaction_strategy`),
465    /// **not** from the `compaction_manager` field (which retains its
466    /// construction-time strategy). The agent loop reads from config fresh
467    /// each run, so this is the authoritative value.
468    pub fn compaction_strategy(&self) -> oxi_ai::CompactionStrategy {
469        self.inner.read().config.compaction_strategy.clone()
470    }
471
472    /// Run the agent with a prompt, collecting all events into a vector.
473    ///
474    /// Convenience wrapper around [`run_with_channel`](Self::run_with_channel) that gathers every
475    /// [`AgentEvent`] produced during the run.
476    pub async fn run(&self, prompt: String) -> Result<(Response, Vec<AgentEvent>)> {
477        let mut events = Vec::new();
478        let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
479        let result = self.run_with_channel(prompt, tx).await;
480        while let Ok(event) = rx.recv() {
481            events.push(event);
482        }
483        result.map(|r| (r, events))
484    }
485
486    /// Run the agent, delivering events through the provided channel.
487    ///
488    /// Delegates to the agent loop which implements the same 2-level agentic
489    /// loop matching pi-mono's architecture:
490    ///
491    /// ```text
492    /// AgentLoop.run_messages()
493    ///   Outer loop (follow-up messages):
494    ///     Inner loop (tool calls + steering):
495    ///       1. Inject pending messages (steering)
496    ///       2. Compaction check
497    ///       3. Stream LLM response (with accumulated partial messages)
498    ///       4. Execute tool calls if any
499    ///       5. Emit turn_end
500    ///       6. Check shouldStopAfterTurn
501    ///       7. Poll steering messages
502    ///     Check follow-up messages
503    ///     Exit
504    /// ```
505    pub async fn run_with_channel(
506        &self,
507        prompt: String,
508        tx: std::sync::mpsc::Sender<AgentEvent>,
509    ) -> Result<Response> {
510        self.run_with_channel_message(oxi_ai::Message::User(oxi_ai::UserMessage::new(prompt)), tx)
511            .await
512    }
513
514    /// Run with an explicit user `Message` (supports image content blocks).
515    /// Used by RPC `prompt` with images. The running-guard logic lives here;
516    /// [`run_with_channel`](Self::run_with_channel) delegates after converting
517    /// its String prompt into a text-only user message.
518    pub async fn run_with_channel_message(
519        &self,
520        prompt: oxi_ai::Message,
521        tx: std::sync::mpsc::Sender<AgentEvent>,
522    ) -> Result<Response> {
523        // pi-mono: Agent.prompt() throws if activeRun exists.
524        // Prevent concurrent runs that would corrupt shared state.
525        if self
526            .is_running
527            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
528            .is_err()
529        {
530            return Err(Error::msg("Agent is already running"));
531        }
532
533        // Drop guard ensures is_running is cleared even on panic.
534        struct RunningGuard<'a>(&'a AtomicBool);
535        impl Drop for RunningGuard<'_> {
536            fn drop(&mut self) {
537                self.0.store(false, Ordering::SeqCst);
538            }
539        }
540        let _guard = RunningGuard(&self.is_running);
541        self.reset_cancel();
542
543        self.run_with_channel_inner(prompt, tx).await
544    }
545
546    /// Inner implementation of run_with_channel, called after the running guard is set.
547    async fn run_with_channel_inner(
548        &self,
549        prompt: oxi_ai::Message,
550        tx: std::sync::mpsc::Sender<AgentEvent>,
551    ) -> Result<Response> {
552        use crate::agent_loop::AgentLoop;
553
554        let (
555            provider,
556            system_prompt,
557            temperature,
558            max_tokens,
559            compaction_strategy,
560            context_window,
561            workspace_dir,
562        ) = {
563            let inner = self.inner.read();
564            (
565                Arc::clone(&inner.provider) as Arc<dyn Provider>,
566                inner.config.system_prompt.clone(),
567                inner.config.temperature,
568                inner.config.max_tokens,
569                inner.config.compaction_strategy.clone(),
570                inner.config.context_window,
571                inner.config.workspace_dir.clone(),
572            )
573        }; // release read lock
574
575        // Build AgentLoopConfig from Agent's config
576        let loop_config = crate::agent_loop::config::AgentLoopConfig {
577            model_id: self.model_id(),
578            system_prompt,
579            temperature: temperature.unwrap_or(1.0) as f32,
580            max_tokens: max_tokens.unwrap_or(4096) as u32,
581            tool_execution: crate::config::ToolExecutionMode::Sequential,
582            compaction_strategy,
583            compaction_instruction: None,
584            context_window,
585            session_id: self.config().config.session_id.clone(),
586            transport: None,
587            compact_on_start: false,
588            max_retry_delay_ms: None,
589            auto_retry_enabled: true,
590            auto_retry_max_attempts: 3,
591            auto_retry_base_delay_ms: 1000,
592            workspace_dir,
593            provider_options: self.config().config.provider_options.clone(),
594            on_compaction: None,
595            ttsr_engine: self.config().config.ttsr_engine.clone(),
596            memory: self.config().config.memory.clone(),
597            todo: self.config().config.todo.clone(),
598            agent_pool: self.config().config.agent_pool.clone(),
599            url_resolver: self.config().config.url_resolver.clone(),
600            lsp: self.config().config.lsp.clone(),
601            snapshot_store: self.config().config.snapshot_store.clone(),
602            max_tool_result_bytes: self.config().config.max_tool_result_bytes,
603            subagent_runner: self.config().config.subagent_runner.clone(),
604            subagent_depth: self.config().config.subagent_depth,
605            ..Default::default()
606        };
607
608        // Create AgentLoop. We give it a NEW SharedState and sync back after.
609        // (SharedState is not Clone, so we create a fresh one from current state)
610        let fresh_state = crate::state::SharedState::new();
611        let current = self.state.get_state();
612        fresh_state.update(|s| {
613            *s = current;
614        });
615
616        let mut agent_loop = AgentLoop::new_with_resolver(
617            provider,
618            loop_config,
619            Arc::clone(&self.tools),
620            fresh_state,
621            Arc::clone(&self.resolver),
622        );
623
624        // Add the user prompt to Agent.state() AFTER fresh_state is created.
625        // fresh_state got a copy of the pre-prompt state, so run_loop will
626        // add the prompt to fresh_state independently via initial_prompts.
627        // But persist_session() reads Agent.state() (not fresh_state), so it
628        // needs the user prompt there to write it to the session file.
629        // Sync happens at AgentEnd (after run_loop completes), where
630        // Agent.state is overwritten with fresh_state (which has all messages).
631        self.state.update(|s| {
632            s.messages.push(prompt.clone());
633        });
634
635        // Pre-populate steering/follow-up from hooks
636        {
637            let hooks = self.hooks.read();
638            if let Some(ref get_steering) = hooks.get_steering_messages {
639                for msg in get_steering() {
640                    agent_loop.steer(msg);
641                }
642            }
643            if let Some(ref get_follow_up) = hooks.get_follow_up_messages {
644                for msg in get_follow_up() {
645                    agent_loop.follow_up(msg);
646                }
647            }
648
649            // Store hooks on AgentLoop so they can be polled each turn
650            // to pick up new messages injected during the run.
651            if let Some(ref get_steering) = hooks.get_steering_messages {
652                agent_loop.set_steering_hook(Arc::clone(get_steering));
653            }
654            if let Some(ref get_follow_up) = hooks.get_follow_up_messages {
655                agent_loop.set_follow_up_hook(Arc::clone(get_follow_up));
656            }
657        }
658        let mut al = agent_loop;
659
660        // Wire should_stop_after_turn hook: share AgentLoop's external_stop
661        // Arc with the emit callback. When the hook fires (Ctrl+C detected),
662        // it sets ext_stop. AgentLoop checks this in should_stop_after_turn()
663        // AND during streaming (streaming.rs checks external_stop each event).
664        //
665        // Arc<dyn Fn> can be cloned, so we read it without consuming.
666        let maybe_hook = {
667            let hooks_r = self.hooks.read();
668            hooks_r.should_stop_after_turn.clone()
669        };
670        let ext_stop = al.external_stop().clone();
671        let cancel_flag = self.cancel_flag.clone();
672
673        // Share cancel_flag with AgentLoop so the streaming loop can check
674        // it directly in the periodic timer — no emit callback required.
675        // This closes the gap where cancel() was ineffective when the
676        // provider stream produced no events.
677        al.set_cancel_signal(self.cancel_flag.clone());
678        let (ar_enabled, ar_cancel, ar_notify) = self.auto_retry_state();
679        al.set_auto_retry_state(ar_enabled, ar_cancel, ar_notify);
680
681        // Create emit callback that sends through the channel.
682        // AgentLoop calls this synchronously. UnboundedSender::send() is
683        // non-blocking and never drops events (unlike try_send on bounded).
684        let tx_emit = tx.clone();
685
686        // Snapshot the observability_dispatch list once per run. This avoids
687        // holding an Agent lock on the emit-fn hot path while still letting
688        // SDK consumers register new dispatchers at any time (registers after
689        // this snapshot will fire on the next run).
690        let dispatch_handlers: Vec<EventDispatchFn> =
691            { self.inner.read().observability_dispatch.lock().clone() };
692        tracing::info!("[AGENT] Starting agent run with channel");
693        let result = al
694            .run_message(prompt.clone(), move |event: AgentEvent| {
695                // Forward event to channel (std::sync::mpsc — send from sync context)
696                tracing::info!("[AGENT-EMIT] Event: {:?}", std::mem::discriminant(&event));
697                if let Err(e) = tx_emit.send(event.clone()) {
698                    tracing::error!(
699                        "[AGENT-EMIT] Failed to send agent event to channel: {:?}",
700                        e
701                    );
702                } else {
703                    tracing::info!("[AGENT-EMIT] Successfully sent event");
704                }
705
706                // Propagate cancellation from Agent::cancel() → external_stop.
707                // This runs on every event, ensuring the streaming loop detects
708                // cancellation promptly.
709                if cancel_flag.load(Ordering::SeqCst) {
710                    ext_stop.store(true, Ordering::SeqCst);
711                }
712
713                // Fan out to SDK-side observability handlers (Tracer,
714                // CostTracker, ...). The dispatch list is snapshotted at
715                // run-start so we hold Arc clones, not a lock. This means
716                // handlers added mid-run do not fire until the next run.
717                for handler in dispatch_handlers.iter() {
718                    handler(event.clone());
719                }
720                // Propagate should_stop → external_stop on every event, not
721                // just TurnEnd. The TUI hook only checks should_stop_flag.load(),
722                // so the context contents are irrelevant for non-TurnEnd events.
723                // This ensures streaming.rs detects cancellation immediately
724                // when the user presses Ctrl+C mid-stream.
725                if let Some(ref hook) = maybe_hook {
726                    let ctx = ShouldStopAfterTurnContext {
727                        message: match &event {
728                            AgentEvent::TurnEnd {
729                                assistant_message: oxi_ai::Message::Assistant(a),
730                                ..
731                            } => a.clone(),
732                            _ => oxi_ai::AssistantMessage::new(
733                                oxi_ai::Api::OpenAiCompletions,
734                                "agent",
735                                "agent-model",
736                            ),
737                        },
738                        tool_results: match &event {
739                            AgentEvent::TurnEnd { tool_results, .. } => tool_results.clone(),
740                            _ => Vec::new(),
741                        },
742                        iteration: 0,
743                    };
744                    if hook(&ctx) {
745                        ext_stop.store(true, Ordering::SeqCst);
746                    }
747                }
748            })
749            .await;
750
751        match result {
752            Ok(_events) => {
753                // Sync state back from AgentLoop
754                let loop_state = al.state().get_state();
755                self.state.update(|s| {
756                    *s = loop_state;
757                });
758
759                // Apply any pending model switch that was deferred during the run.
760                // This transforms messages (if cross-provider) and swaps the provider
761                // so the next run uses the new model.
762                self.apply_pending_model_switch();
763
764                // Extract final response text from state
765                let state = self.state.get_state();
766                let final_text = state
767                    .messages
768                    .iter()
769                    .rev()
770                    .find_map(|m| match m {
771                        oxi_ai::Message::Assistant(a) => a.content.iter().find_map(|b| match b {
772                            oxi_ai::ContentBlock::Text(t) => Some(t.text.clone()),
773                            _ => None,
774                        }),
775                        _ => None,
776                    })
777                    .unwrap_or_default();
778
779                let stop_reason = state.stop_reason.unwrap_or(StopReason::Stop);
780
781                Ok(Response {
782                    content: final_text,
783                    stop_reason,
784                })
785            }
786            Err(e) => {
787                // Apply pending model switch even on error so the next run
788                // uses the new model.
789                self.apply_pending_model_switch();
790                Err(e)
791            }
792        }
793    }
794
795    // ── Helper methods for the agentic loop ────────────────────────
796
797    /// Set hooks for the agent loop.
798    pub fn set_hooks(&self, hooks: crate::config::AgentHooks) {
799        let mut h = self.hooks.write();
800        *h = hooks;
801    }
802
803    /// Register a side-dispatch closure called for every `AgentEvent`
804    /// emitted by `run`, `run_with_channel`, `run_streaming`,
805    /// `run_tokio_stream`, and `continue_with`.
806    ///
807    /// Multiple calls stack: every registered closure is invoked on
808    /// every event. Closures run synchronously on the agent-loop emit
809    /// thread, so they must be cheap and non-blocking. Long work
810    /// should be spawned off (e.g. `tokio::spawn`) by the closure
811    /// itself.
812    ///
813    /// Used by `oxi-sdk` to bridge observability types
814    /// (`Tracer`, `CostTracker`, `AuditLog`, `Authorizer` /
815    /// `AccessGate`) into the runtime without leaking those types
816    /// into `oxi-agent`.
817    ///
818    /// # Example
819    ///
820    /// ```ignore
821    /// agent.add_observability_dispatch(|event| match event {
822    ///     AgentEvent::TurnStart { turn_number } => {
823    ///         // open a span
824    ///     }
825    ///     AgentEvent::Usage { input_tokens, output_tokens } => {
826    ///         // record cost
827    ///     }
828    ///     _ => {}
829    /// });
830    /// ```
831    pub fn add_observability_dispatch(&self, f: impl Fn(AgentEvent) + Send + Sync + 'static) {
832        let guard = self.inner.write();
833        let mut slot = guard.observability_dispatch.lock();
834        slot.push(Arc::new(f));
835    }
836
837    /// Request cancellation of the current agent run.
838    ///
839    /// Sets a shared `cancel_flag` that is propagated to the `AgentLoop`'s
840    /// `external_stop` on every event AND polled every ~500ms by the
841    /// streaming loop's periodic check. This ensures cancellation is
842    /// detected quickly even when the provider stream is completely hung
843    /// (no events arriving).
844    pub fn cancel(&self) {
845        self.cancel_flag.store(true, Ordering::SeqCst);
846    }
847
848    /// Toggle auto-retry at runtime (affects the next retry decision in an
849    /// active run; does not interrupt an in-progress retry sleep — use
850    /// [`Self::cancel_auto_retry`] for that).
851    pub fn set_auto_retry(&self, enabled: bool) {
852        self.auto_retry_enabled.store(enabled, Ordering::SeqCst);
853    }
854
855    /// Abort any in-progress auto-retry wait immediately. The running turn
856    /// ends without retrying the error.
857    pub fn cancel_auto_retry(&self) {
858        self.auto_retry_cancel.store(true, Ordering::SeqCst);
859        self.auto_retry_notify.notify_waiters();
860    }
861
862    /// Shared auto-retry state (enabled + cancel + notify) for injection
863    /// into an ephemeral `AgentLoop` at run-start.
864    pub(crate) fn auto_retry_state(
865        &self,
866    ) -> (Arc<AtomicBool>, Arc<AtomicBool>, Arc<tokio::sync::Notify>) {
867        (
868            Arc::clone(&self.auto_retry_enabled),
869            Arc::clone(&self.auto_retry_cancel),
870            Arc::clone(&self.auto_retry_notify),
871        )
872    }
873
874    /// Reset the cancellation flag before starting a new run.
875    pub fn reset_cancel(&self) {
876        self.cancel_flag.store(false, Ordering::SeqCst);
877    }
878
879    /// Apply any pending model switch that was deferred during a running loop.
880    ///
881    /// Called after `run_with_channel_inner` completes (success or error).
882    /// Transforms messages for cross-provider switches and swaps the provider
883    /// so the next run uses the new model.
884    fn apply_pending_model_switch(&self) {
885        let pending = self.pending_model_switch.write().take();
886        if let Some(pending) = pending {
887            tracing::info!(
888                "[AGENT] Applying deferred model switch to '{}' (transform={})",
889                pending.model_id,
890                pending.needs_transform
891            );
892
893            // Transform messages if cross-provider
894            if pending.needs_transform {
895                let messages = self.state.get_state().messages.clone();
896                let transformed =
897                    transform_for_provider(&messages, &pending.old_api, &pending.new_api);
898                self.state.update(|s| {
899                    s.replace_messages(transformed);
900                });
901            }
902
903            // Swap the provider
904            let mut inner = self.inner_mut();
905            inner.provider = pending.provider;
906            // model_id was already updated in switch_model()
907        }
908    }
909
910    /// Run the agent, invoking `on_event` for each [`AgentEvent`] produced.
911    ///
912    /// Blocking convenience wrapper suitable for callers that prefer a
913    /// callback-based API over a channel.
914    pub async fn run_streaming<F>(&self, prompt: String, mut on_event: F) -> Result<Response>
915    where
916        F: FnMut(AgentEvent) + Send,
917    {
918        let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
919        let result = self.run_with_channel(prompt, tx).await;
920        while let Ok(event) = rx.recv() {
921            on_event(event);
922        }
923        result
924    }
925
926    // ── Session persistence ────────────────────────────────────────
927
928    /// Export the agent state as a JSON value.
929    ///
930    /// The serialized state includes conversation messages, token counts,
931    /// iteration progress, and stop reason. Use [`import_state`] to restore.
932    ///
933    /// [`import_state`]: Agent::import_state
934    pub fn export_state(&self) -> Result<serde_json::Value> {
935        let state = self.state.get_state();
936        serde_json::to_value(&state).map_err(|e| Error::msg(format!("State export failed: {}", e)))
937    }
938
939    /// Import agent state from a JSON value.
940    ///
941    /// Restores conversation history, token counts, and iteration progress.
942    /// Typically used together with [`export_state`] for session persistence.
943    ///
944    /// [`export_state`]: Agent::export_state
945    pub fn import_state(&self, value: serde_json::Value) -> Result<()> {
946        let state: AgentState = serde_json::from_value(value)
947            .map_err(|e| Error::msg(format!("State import failed: {}", e)))?;
948        self.state.update(|s| *s = state);
949        Ok(())
950    }
951
952    // ── Session continuation ───────────────────────────────────────
953
954    /// Continue the current session with a new prompt.
955    ///
956    /// Unlike `run()`, which can be used on a fresh agent, `continue_with`
957    /// preserves the existing conversation state and appends the new prompt.
958    /// This enables multi-turn interactions within the same session.
959    pub async fn continue_with(&self, prompt: String) -> Result<(Response, Vec<AgentEvent>)> {
960        let mut events = Vec::new();
961        let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
962        let result = self.run_with_channel(prompt, tx).await;
963        while let Ok(event) = rx.recv() {
964            events.push(event);
965        }
966        result.map(|r| (r, events))
967    }
968
969    // ── Tokio-native streaming ─────────────────────────────────────
970
971    /// Run the agent with tokio-native event streaming.
972    ///
973    /// Returns a `tokio::sync::mpsc::Receiver` for events and a
974    /// `JoinHandle` for the response. This is the preferred API for
975    /// async runtimes (WebSocket/SSE gateways, tokio-based servers).
976    ///
977    /// # Example
978    ///
979    /// ```ignore
980    /// let (rx, handle) = agent.run_tokio_stream("Explain Rust".into()).await?;
981    /// while let Some(event) = rx.recv().await {
982    ///     println!("Event: {:?}", event.type_name());
983    /// }
984    /// let response = handle.await??;
985    /// ```
986    pub async fn run_tokio_stream(
987        &self,
988        prompt: String,
989    ) -> Result<(
990        tokio::sync::mpsc::Receiver<AgentEvent>,
991        tokio::task::JoinHandle<Result<Response>>,
992    )> {
993        let (tx, rx) = tokio::sync::mpsc::channel::<AgentEvent>(256);
994
995        if self
996            .is_running
997            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
998            .is_err()
999        {
1000            return Err(Error::msg("Agent is already running"));
1001        }
1002
1003        let should_stop_hook = self.hooks.read().should_stop_after_turn.clone();
1004
1005        let inner = self.inner.read().clone();
1006        let tools = Arc::clone(&self.tools);
1007        let resolver = Arc::clone(&self.resolver);
1008
1009        // Build AgentLoopConfig
1010        let loop_config = crate::agent_loop::config::AgentLoopConfig {
1011            model_id: inner.config.model_id.clone(),
1012            system_prompt: inner.config.system_prompt.clone(),
1013            temperature: inner.config.temperature.unwrap_or(1.0) as f32,
1014            max_tokens: inner.config.max_tokens.unwrap_or(4096) as u32,
1015            tool_execution: crate::config::ToolExecutionMode::Sequential,
1016            compaction_strategy: inner.config.compaction_strategy.clone(),
1017            compaction_instruction: None,
1018            context_window: inner.config.context_window,
1019            session_id: inner.config.session_id.clone(),
1020            transport: None,
1021            compact_on_start: false,
1022            max_retry_delay_ms: None,
1023            auto_retry_enabled: true,
1024            auto_retry_max_attempts: 3,
1025            auto_retry_base_delay_ms: 1000,
1026            workspace_dir: inner.config.workspace_dir.clone(),
1027            provider_options: inner.config.provider_options.clone(),
1028            on_compaction: None,
1029            ttsr_engine: inner.config.ttsr_engine.clone(),
1030            max_tool_result_bytes: inner.config.max_tool_result_bytes,
1031            subagent_runner: inner.config.subagent_runner.clone(),
1032            subagent_depth: inner.config.subagent_depth,
1033            memory: inner.config.memory.clone(),
1034            todo: inner.config.todo.clone(),
1035            agent_pool: inner.config.agent_pool.clone(),
1036            url_resolver: inner.config.url_resolver.clone(),
1037            lsp: inner.config.lsp.clone(),
1038            snapshot_store: inner.config.snapshot_store.clone(),
1039            ..Default::default()
1040        };
1041
1042        let provider: Arc<dyn Provider> = Arc::clone(&inner.provider);
1043
1044        // Share the SAME SharedState (Arc<RwLock<AgentState>>) with the
1045        // agent loop so that state mutations inside the spawned task are
1046        // visible through self.state() without an explicit sync step.
1047        //
1048        // Unlike run_with_channel_inner which creates a fresh SharedState
1049        // and syncs back on completion, the tokio streaming API cannot
1050        // access `self` inside the `'static` spawned task, so we share
1051        // the underlying Arc instead.
1052        //
1053        // Pre-load current state into the shared Arc (in case it was
1054        // modified by a previous run that used a different SharedState).
1055        let shared_state = self.state.clone();
1056
1057        let mut agent_loop = crate::agent_loop::AgentLoop::new_with_resolver(
1058            provider,
1059            loop_config,
1060            tools,
1061            shared_state.clone(),
1062            resolver,
1063        );
1064
1065        let maybe_hook = should_stop_hook;
1066        let ext_stop = agent_loop.external_stop().clone();
1067        let (ar_enabled, ar_cancel, ar_notify) = self.auto_retry_state();
1068        agent_loop.set_auto_retry_state(ar_enabled, ar_cancel, ar_notify);
1069
1070        // Clone the is_running Arc so the spawned task can clear it.
1071        let is_running_flag = Arc::clone(&self.is_running);
1072
1073        // Snapshot the observability_dispatch list before the spawned
1074        // task. The future is `'static` and cannot borrow `&self`,
1075        // so we take the snapshot at run-start on the regular borrow
1076        // stack and move the resulting Arc-clones into the task.
1077        let dispatch_handlers: Vec<EventDispatchFn> = {
1078            let guard = self.inner.read();
1079            guard.observability_dispatch.lock().clone()
1080        };
1081
1082        let handle = tokio::task::spawn(async move {
1083            let result = agent_loop
1084                .run(prompt, move |event: AgentEvent| {
1085                    // Forward to tokio channel (non-blocking)
1086                    let _ = tx.try_send(event.clone());
1087
1088                    // Fan out to SDK-side observability handlers
1089                    // (Tracer, CostTracker, ...).
1090                    for handler in dispatch_handlers.iter() {
1091                        handler(event.clone());
1092                    }
1093                    // Propagate should_stop → external_stop on every event,
1094                    // not just TurnEnd. See run_with_channel_inner for rationale.
1095                    if let Some(ref hook) = maybe_hook {
1096                        let ctx = ShouldStopAfterTurnContext {
1097                            message: match &event {
1098                                AgentEvent::TurnEnd {
1099                                    assistant_message: oxi_ai::Message::Assistant(a),
1100                                    ..
1101                                } => a.clone(),
1102                                _ => oxi_ai::AssistantMessage::new(
1103                                    oxi_ai::Api::OpenAiCompletions,
1104                                    "agent",
1105                                    "agent-model",
1106                                ),
1107                            },
1108                            tool_results: match &event {
1109                                AgentEvent::TurnEnd { tool_results, .. } => tool_results.clone(),
1110                                _ => Vec::new(),
1111                            },
1112                            iteration: 0,
1113                        };
1114                        if hook(&ctx) {
1115                            ext_stop.store(true, Ordering::SeqCst);
1116                        }
1117                    }
1118                })
1119                .await;
1120
1121            // Clear the Agent's running flag
1122            is_running_flag.store(false, Ordering::SeqCst);
1123
1124            match result {
1125                Ok(_events) => {
1126                    // State is already shared via the same SharedState Arc,
1127                    // so self.state() will reflect all mutations.
1128                    Ok(Response {
1129                        content: String::new(),
1130                        stop_reason: StopReason::Stop,
1131                    })
1132                }
1133                Err(e) => Err(e),
1134            }
1135        });
1136
1137        Ok((rx, handle))
1138    }
1139}