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