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