Skip to main content

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