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