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}