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