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, 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 (`oxicode-sdk`), the `Oxicode` 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 `oxicode_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 oxicode_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 `oxicode-sdk` to bridge observability
56 /// types (Tracer, CostTracker, ...) into the agent loop without
57 /// leaking SDK types into `oxicode-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: oxicode_ai::Api,
103 new_api: oxicode_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 `oxicode_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(oxicode_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: &oxicode_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(oxicode_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: oxicode_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) -> oxicode_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(
511 oxicode_ai::Message::User(oxicode_ai::UserMessage::new(prompt)),
512 tx,
513 )
514 .await
515 }
516
517 /// Run with an explicit user `Message` (supports image content blocks).
518 /// Used by RPC `prompt` with images. The running-guard logic lives here;
519 /// [`run_with_channel`](Self::run_with_channel) delegates after converting
520 /// its String prompt into a text-only user message.
521 pub async fn run_with_channel_message(
522 &self,
523 prompt: oxicode_ai::Message,
524 tx: std::sync::mpsc::Sender<AgentEvent>,
525 ) -> Result<Response> {
526 // pi-mono: Agent.prompt() throws if activeRun exists.
527 // Prevent concurrent runs that would corrupt shared state.
528 if self
529 .is_running
530 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
531 .is_err()
532 {
533 return Err(Error::msg("Agent is already running"));
534 }
535
536 // Drop guard ensures is_running is cleared even on panic.
537 struct RunningGuard<'a>(&'a AtomicBool);
538 impl Drop for RunningGuard<'_> {
539 fn drop(&mut self) {
540 self.0.store(false, Ordering::SeqCst);
541 }
542 }
543 let _guard = RunningGuard(&self.is_running);
544 self.reset_cancel();
545
546 self.run_with_channel_inner(prompt, tx).await
547 }
548
549 /// Inner implementation of run_with_channel, called after the running guard is set.
550 async fn run_with_channel_inner(
551 &self,
552 prompt: oxicode_ai::Message,
553 tx: std::sync::mpsc::Sender<AgentEvent>,
554 ) -> Result<Response> {
555 use crate::agent_loop::AgentLoop;
556
557 let (
558 provider,
559 system_prompt,
560 temperature,
561 max_tokens,
562 compaction_strategy,
563 context_window,
564 workspace_dir,
565 ) = {
566 let inner = self.inner.read();
567 (
568 Arc::clone(&inner.provider) as Arc<dyn Provider>,
569 inner.config.system_prompt.clone(),
570 inner.config.temperature,
571 inner.config.max_tokens,
572 inner.config.compaction_strategy.clone(),
573 inner.config.context_window,
574 inner.config.workspace_dir.clone(),
575 )
576 }; // release read lock
577
578 // Build AgentLoopConfig from Agent's config
579 let loop_config = crate::agent_loop::config::AgentLoopConfig {
580 model_id: self.model_id(),
581 system_prompt,
582 temperature: temperature.unwrap_or(1.0) as f32,
583 max_tokens: max_tokens.unwrap_or(4096) as u32,
584 tool_execution: crate::config::ToolExecutionMode::Sequential,
585 compaction_strategy,
586 compaction_instruction: None,
587 context_window,
588 session_id: self.config().config.session_id.clone(),
589 transport: None,
590 compact_on_start: false,
591 max_retry_delay_ms: None,
592 auto_retry_enabled: true,
593 auto_retry_max_attempts: 3,
594 auto_retry_base_delay_ms: 1000,
595 workspace_dir,
596 provider_options: self.config().config.provider_options.clone(),
597 on_compaction: None,
598 ttsr_engine: self.config().config.ttsr_engine.clone(),
599 memory: self.config().config.memory.clone(),
600 todo: self.config().config.todo.clone(),
601 agent_pool: self.config().config.agent_pool.clone(),
602 url_resolver: self.config().config.url_resolver.clone(),
603 lsp: self.config().config.lsp.clone(),
604 snapshot_store: self.config().config.snapshot_store.clone(),
605 max_tool_result_bytes: self.config().config.max_tool_result_bytes,
606 subagent_runner: self.config().config.subagent_runner.clone(),
607 subagent_depth: self.config().config.subagent_depth,
608 ..Default::default()
609 };
610
611 // Create AgentLoop. We give it a NEW SharedState and sync back after.
612 // (SharedState is not Clone, so we create a fresh one from current state)
613 let fresh_state = crate::state::SharedState::new();
614 let current = self.state.get_state();
615 fresh_state.update(|s| {
616 *s = current;
617 });
618
619 let mut agent_loop = AgentLoop::new_with_resolver(
620 provider,
621 loop_config,
622 Arc::clone(&self.tools),
623 fresh_state,
624 Arc::clone(&self.resolver),
625 );
626
627 // Add the user prompt to Agent.state() AFTER fresh_state is created.
628 // fresh_state got a copy of the pre-prompt state, so run_loop will
629 // add the prompt to fresh_state independently via initial_prompts.
630 // But persist_session() reads Agent.state() (not fresh_state), so it
631 // needs the user prompt there to write it to the session file.
632 // Sync happens at AgentEnd (after run_loop completes), where
633 // Agent.state is overwritten with fresh_state (which has all messages).
634 self.state.update(|s| {
635 s.messages.push(prompt.clone());
636 });
637
638 // Pre-populate steering/follow-up from hooks
639 {
640 let hooks = self.hooks.read();
641 if let Some(ref get_steering) = hooks.get_steering_messages {
642 for msg in get_steering() {
643 agent_loop.steer(msg);
644 }
645 }
646 if let Some(ref get_follow_up) = hooks.get_follow_up_messages {
647 for msg in get_follow_up() {
648 agent_loop.follow_up(msg);
649 }
650 }
651
652 // Store hooks on AgentLoop so they can be polled each turn
653 // to pick up new messages injected during the run.
654 if let Some(ref get_steering) = hooks.get_steering_messages {
655 agent_loop.set_steering_hook(Arc::clone(get_steering));
656 }
657 if let Some(ref get_follow_up) = hooks.get_follow_up_messages {
658 agent_loop.set_follow_up_hook(Arc::clone(get_follow_up));
659 }
660 }
661 let mut al = agent_loop;
662
663 // Wire should_stop_after_turn hook: share AgentLoop's external_stop
664 // Arc with the emit callback. When the hook fires (Ctrl+C detected),
665 // it sets ext_stop. AgentLoop checks this in should_stop_after_turn()
666 // AND during streaming (streaming.rs checks external_stop each event).
667 //
668 // Arc<dyn Fn> can be cloned, so we read it without consuming.
669 let maybe_hook = {
670 let hooks_r = self.hooks.read();
671 hooks_r.should_stop_after_turn.clone()
672 };
673 let ext_stop = al.external_stop().clone();
674 let cancel_flag = self.cancel_flag.clone();
675
676 // Share cancel_flag with AgentLoop so the streaming loop can check
677 // it directly in the periodic timer — no emit callback required.
678 // This closes the gap where cancel() was ineffective when the
679 // provider stream produced no events.
680 al.set_cancel_signal(self.cancel_flag.clone());
681 let (ar_enabled, ar_cancel, ar_notify) = self.auto_retry_state();
682 al.set_auto_retry_state(ar_enabled, ar_cancel, ar_notify);
683
684 // Create emit callback that sends through the channel.
685 // AgentLoop calls this synchronously. UnboundedSender::send() is
686 // non-blocking and never drops events (unlike try_send on bounded).
687 let tx_emit = tx.clone();
688
689 // Snapshot the observability_dispatch list once per run. This avoids
690 // holding an Agent lock on the emit-fn hot path while still letting
691 // SDK consumers register new dispatchers at any time (registers after
692 // this snapshot will fire on the next run).
693 let dispatch_handlers: Vec<EventDispatchFn> =
694 { self.inner.read().observability_dispatch.lock().clone() };
695 tracing::info!("[AGENT] Starting agent run with channel");
696 let result = al
697 .run_message(prompt.clone(), move |event: AgentEvent| {
698 // Forward event to channel (std::sync::mpsc — send from sync context)
699 tracing::info!("[AGENT-EMIT] Event: {:?}", std::mem::discriminant(&event));
700 if let Err(e) = tx_emit.send(event.clone()) {
701 tracing::error!(
702 "[AGENT-EMIT] Failed to send agent event to channel: {:?}",
703 e
704 );
705 } else {
706 tracing::info!("[AGENT-EMIT] Successfully sent event");
707 }
708
709 // Propagate cancellation from Agent::cancel() → external_stop.
710 // This runs on every event, ensuring the streaming loop detects
711 // cancellation promptly.
712 if cancel_flag.load(Ordering::SeqCst) {
713 ext_stop.store(true, Ordering::SeqCst);
714 }
715
716 // Fan out to SDK-side observability handlers (Tracer,
717 // CostTracker, ...). The dispatch list is snapshotted at
718 // run-start so we hold Arc clones, not a lock. This means
719 // handlers added mid-run do not fire until the next run.
720 for handler in dispatch_handlers.iter() {
721 handler(event.clone());
722 }
723 // Propagate should_stop → external_stop on every event, not
724 // just TurnEnd. The TUI hook only checks should_stop_flag.load(),
725 // so the context contents are irrelevant for non-TurnEnd events.
726 // This ensures streaming.rs detects cancellation immediately
727 // when the user presses Ctrl+C mid-stream.
728 if let Some(ref hook) = maybe_hook {
729 let ctx = ShouldStopAfterTurnContext {
730 message: match &event {
731 AgentEvent::TurnEnd {
732 assistant_message: oxicode_ai::Message::Assistant(a),
733 ..
734 } => a.clone(),
735 _ => oxicode_ai::AssistantMessage::new(
736 oxicode_ai::Api::OpenAiCompletions,
737 "agent",
738 "agent-model",
739 ),
740 },
741 tool_results: match &event {
742 AgentEvent::TurnEnd { tool_results, .. } => tool_results.clone(),
743 _ => Vec::new(),
744 },
745 iteration: 0,
746 };
747 if hook(&ctx) {
748 ext_stop.store(true, Ordering::SeqCst);
749 }
750 }
751 })
752 .await;
753
754 match result {
755 Ok(_events) => {
756 // Sync state back from AgentLoop
757 let loop_state = al.state().get_state();
758 self.state.update(|s| {
759 *s = loop_state;
760 });
761
762 // Apply any pending model switch that was deferred during the run.
763 // This transforms messages (if cross-provider) and swaps the provider
764 // so the next run uses the new model.
765 self.apply_pending_model_switch();
766
767 // Extract final response text from state
768 let state = self.state.get_state();
769 let final_text = state
770 .messages
771 .iter()
772 .rev()
773 .find_map(|m| match m {
774 oxicode_ai::Message::Assistant(a) => {
775 a.content.iter().find_map(|b| match b {
776 oxicode_ai::ContentBlock::Text(t) => Some(t.text.clone()),
777 _ => None,
778 })
779 }
780 _ => None,
781 })
782 .unwrap_or_default();
783
784 let stop_reason = state.stop_reason.unwrap_or(StopReason::Stop);
785
786 Ok(Response {
787 content: final_text,
788 stop_reason,
789 })
790 }
791 Err(e) => {
792 // Apply pending model switch even on error so the next run
793 // uses the new model.
794 self.apply_pending_model_switch();
795 Err(e)
796 }
797 }
798 }
799
800 // ── Helper methods for the agentic loop ────────────────────────
801
802 /// Set hooks for the agent loop.
803 pub fn set_hooks(&self, hooks: crate::config::AgentHooks) {
804 let mut h = self.hooks.write();
805 *h = hooks;
806 }
807
808 /// Register a side-dispatch closure called for every `AgentEvent`
809 /// emitted by `run`, `run_with_channel`, `run_streaming`,
810 /// `run_tokio_stream`, and `continue_with`.
811 ///
812 /// Multiple calls stack: every registered closure is invoked on
813 /// every event. Closures run synchronously on the agent-loop emit
814 /// thread, so they must be cheap and non-blocking. Long work
815 /// should be spawned off (e.g. `tokio::spawn`) by the closure
816 /// itself.
817 ///
818 /// Used by `oxicode-sdk` to bridge observability types
819 /// (`Tracer`, `CostTracker`, `AuditLog`, `Authorizer` /
820 /// `AccessGate`) into the runtime without leaking those types
821 /// into `oxicode-agent`.
822 ///
823 /// # Example
824 ///
825 /// ```ignore
826 /// agent.add_observability_dispatch(|event| match event {
827 /// AgentEvent::TurnStart { turn_number } => {
828 /// // open a span
829 /// }
830 /// AgentEvent::Usage { input_tokens, output_tokens } => {
831 /// // record cost
832 /// }
833 /// _ => {}
834 /// });
835 /// ```
836 pub fn add_observability_dispatch(&self, f: impl Fn(AgentEvent) + Send + Sync + 'static) {
837 let guard = self.inner.write();
838 let mut slot = guard.observability_dispatch.lock();
839 slot.push(Arc::new(f));
840 }
841
842 /// Request cancellation of the current agent run.
843 ///
844 /// Sets a shared `cancel_flag` that is propagated to the `AgentLoop`'s
845 /// `external_stop` on every event AND polled every ~500ms by the
846 /// streaming loop's periodic check. This ensures cancellation is
847 /// detected quickly even when the provider stream is completely hung
848 /// (no events arriving).
849 pub fn cancel(&self) {
850 self.cancel_flag.store(true, Ordering::SeqCst);
851 }
852
853 /// Toggle auto-retry at runtime (affects the next retry decision in an
854 /// active run; does not interrupt an in-progress retry sleep — use
855 /// [`Self::cancel_auto_retry`] for that).
856 pub fn set_auto_retry(&self, enabled: bool) {
857 self.auto_retry_enabled.store(enabled, Ordering::SeqCst);
858 }
859
860 /// Abort any in-progress auto-retry wait immediately. The running turn
861 /// ends without retrying the error.
862 pub fn cancel_auto_retry(&self) {
863 self.auto_retry_cancel.store(true, Ordering::SeqCst);
864 self.auto_retry_notify.notify_waiters();
865 }
866
867 /// Shared auto-retry state (enabled + cancel + notify) for injection
868 /// into an ephemeral `AgentLoop` at run-start.
869 pub(crate) fn auto_retry_state(
870 &self,
871 ) -> (Arc<AtomicBool>, Arc<AtomicBool>, Arc<tokio::sync::Notify>) {
872 (
873 Arc::clone(&self.auto_retry_enabled),
874 Arc::clone(&self.auto_retry_cancel),
875 Arc::clone(&self.auto_retry_notify),
876 )
877 }
878
879 /// Reset the cancellation flag before starting a new run.
880 pub fn reset_cancel(&self) {
881 self.cancel_flag.store(false, Ordering::SeqCst);
882 }
883
884 /// Apply any pending model switch that was deferred during a running loop.
885 ///
886 /// Called after `run_with_channel_inner` completes (success or error).
887 /// Transforms messages for cross-provider switches and swaps the provider
888 /// so the next run uses the new model.
889 fn apply_pending_model_switch(&self) {
890 let pending = self.pending_model_switch.write().take();
891 if let Some(pending) = pending {
892 tracing::info!(
893 "[AGENT] Applying deferred model switch to '{}' (transform={})",
894 pending.model_id,
895 pending.needs_transform
896 );
897
898 // Transform messages if cross-provider
899 if pending.needs_transform {
900 let messages = self.state.get_state().messages.clone();
901 let transformed =
902 transform_for_provider(&messages, &pending.old_api, &pending.new_api);
903 self.state.update(|s| {
904 s.replace_messages(transformed);
905 });
906 }
907
908 // Swap the provider
909 let mut inner = self.inner_mut();
910 inner.provider = pending.provider;
911 // model_id was already updated in switch_model()
912 }
913 }
914
915 /// Run the agent, invoking `on_event` for each [`AgentEvent`] produced.
916 ///
917 /// Blocking convenience wrapper suitable for callers that prefer a
918 /// callback-based API over a channel.
919 pub async fn run_streaming<F>(&self, prompt: String, mut on_event: F) -> Result<Response>
920 where
921 F: FnMut(AgentEvent) + Send,
922 {
923 let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
924 let result = self.run_with_channel(prompt, tx).await;
925 while let Ok(event) = rx.recv() {
926 on_event(event);
927 }
928 result
929 }
930
931 // ── Session persistence ────────────────────────────────────────
932
933 /// Export the agent state as a JSON value.
934 ///
935 /// The serialized state includes conversation messages, token counts,
936 /// iteration progress, and stop reason. Use [`import_state`] to restore.
937 ///
938 /// [`import_state`]: Agent::import_state
939 pub fn export_state(&self) -> Result<serde_json::Value> {
940 let state = self.state.get_state();
941 serde_json::to_value(&state).map_err(|e| Error::msg(format!("State export failed: {}", e)))
942 }
943
944 /// Import agent state from a JSON value.
945 ///
946 /// Restores conversation history, token counts, and iteration progress.
947 /// Typically used together with [`export_state`] for session persistence.
948 ///
949 /// [`export_state`]: Agent::export_state
950 pub fn import_state(&self, value: serde_json::Value) -> Result<()> {
951 let state: AgentState = serde_json::from_value(value)
952 .map_err(|e| Error::msg(format!("State import failed: {}", e)))?;
953 self.state.update(|s| *s = state);
954 Ok(())
955 }
956
957 // ── Session continuation ───────────────────────────────────────
958
959 /// Continue the current session with a new prompt.
960 ///
961 /// Unlike `run()`, which can be used on a fresh agent, `continue_with`
962 /// preserves the existing conversation state and appends the new prompt.
963 /// This enables multi-turn interactions within the same session.
964 pub async fn continue_with(&self, prompt: String) -> Result<(Response, Vec<AgentEvent>)> {
965 let mut events = Vec::new();
966 let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
967 let result = self.run_with_channel(prompt, tx).await;
968 while let Ok(event) = rx.recv() {
969 events.push(event);
970 }
971 result.map(|r| (r, events))
972 }
973
974 // ── Tokio-native streaming ─────────────────────────────────────
975
976 /// Run the agent with tokio-native event streaming.
977 ///
978 /// Returns a `tokio::sync::mpsc::Receiver` for events and a
979 /// `JoinHandle` for the response. This is the preferred API for
980 /// async runtimes (WebSocket/SSE gateways, tokio-based servers).
981 ///
982 /// # Example
983 ///
984 /// ```ignore
985 /// let (rx, handle) = agent.run_tokio_stream("Explain Rust".into()).await?;
986 /// while let Some(event) = rx.recv().await {
987 /// println!("Event: {:?}", event.type_name());
988 /// }
989 /// let response = handle.await??;
990 /// ```
991 pub async fn run_tokio_stream(
992 &self,
993 prompt: String,
994 ) -> Result<(
995 tokio::sync::mpsc::Receiver<AgentEvent>,
996 tokio::task::JoinHandle<Result<Response>>,
997 )> {
998 let (tx, rx) = tokio::sync::mpsc::channel::<AgentEvent>(256);
999
1000 if self
1001 .is_running
1002 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1003 .is_err()
1004 {
1005 return Err(Error::msg("Agent is already running"));
1006 }
1007
1008 let should_stop_hook = self.hooks.read().should_stop_after_turn.clone();
1009
1010 let inner = self.inner.read().clone();
1011 let tools = Arc::clone(&self.tools);
1012 let resolver = Arc::clone(&self.resolver);
1013
1014 // Build AgentLoopConfig
1015 let loop_config = crate::agent_loop::config::AgentLoopConfig {
1016 model_id: inner.config.model_id.clone(),
1017 system_prompt: inner.config.system_prompt.clone(),
1018 temperature: inner.config.temperature.unwrap_or(1.0) as f32,
1019 max_tokens: inner.config.max_tokens.unwrap_or(4096) as u32,
1020 tool_execution: crate::config::ToolExecutionMode::Sequential,
1021 compaction_strategy: inner.config.compaction_strategy.clone(),
1022 compaction_instruction: None,
1023 context_window: inner.config.context_window,
1024 session_id: inner.config.session_id.clone(),
1025 transport: None,
1026 compact_on_start: false,
1027 max_retry_delay_ms: None,
1028 auto_retry_enabled: true,
1029 auto_retry_max_attempts: 3,
1030 auto_retry_base_delay_ms: 1000,
1031 workspace_dir: inner.config.workspace_dir.clone(),
1032 provider_options: inner.config.provider_options.clone(),
1033 on_compaction: None,
1034 ttsr_engine: inner.config.ttsr_engine.clone(),
1035 max_tool_result_bytes: inner.config.max_tool_result_bytes,
1036 subagent_runner: inner.config.subagent_runner.clone(),
1037 subagent_depth: inner.config.subagent_depth,
1038 memory: inner.config.memory.clone(),
1039 todo: inner.config.todo.clone(),
1040 agent_pool: inner.config.agent_pool.clone(),
1041 url_resolver: inner.config.url_resolver.clone(),
1042 lsp: inner.config.lsp.clone(),
1043 snapshot_store: inner.config.snapshot_store.clone(),
1044 ..Default::default()
1045 };
1046
1047 let provider: Arc<dyn Provider> = Arc::clone(&inner.provider);
1048
1049 // Share the SAME SharedState (Arc<RwLock<AgentState>>) with the
1050 // agent loop so that state mutations inside the spawned task are
1051 // visible through self.state() without an explicit sync step.
1052 //
1053 // Unlike run_with_channel_inner which creates a fresh SharedState
1054 // and syncs back on completion, the tokio streaming API cannot
1055 // access `self` inside the `'static` spawned task, so we share
1056 // the underlying Arc instead.
1057 //
1058 // Pre-load current state into the shared Arc (in case it was
1059 // modified by a previous run that used a different SharedState).
1060 let shared_state = self.state.clone();
1061
1062 let mut agent_loop = crate::agent_loop::AgentLoop::new_with_resolver(
1063 provider,
1064 loop_config,
1065 tools,
1066 shared_state.clone(),
1067 resolver,
1068 );
1069
1070 let maybe_hook = should_stop_hook;
1071 let ext_stop = agent_loop.external_stop().clone();
1072 let (ar_enabled, ar_cancel, ar_notify) = self.auto_retry_state();
1073 agent_loop.set_auto_retry_state(ar_enabled, ar_cancel, ar_notify);
1074
1075 // Clone the is_running Arc so the spawned task can clear it.
1076 let is_running_flag = Arc::clone(&self.is_running);
1077
1078 // Snapshot the observability_dispatch list before the spawned
1079 // task. The future is `'static` and cannot borrow `&self`,
1080 // so we take the snapshot at run-start on the regular borrow
1081 // stack and move the resulting Arc-clones into the task.
1082 let dispatch_handlers: Vec<EventDispatchFn> = {
1083 let guard = self.inner.read();
1084 guard.observability_dispatch.lock().clone()
1085 };
1086
1087 let handle = tokio::task::spawn(async move {
1088 // Guard ensures is_running is cleared even if the task panics.
1089 // Without this, a panic mid-stream leaves is_running=true and
1090 // blocks all future runs (the compare_exchange at entry fails).
1091 struct RunningGuard(Arc<AtomicBool>);
1092 impl Drop for RunningGuard {
1093 fn drop(&mut self) {
1094 self.0.store(false, Ordering::SeqCst);
1095 }
1096 }
1097 let _guard = RunningGuard(is_running_flag);
1098
1099 let result = agent_loop
1100 .run(prompt, move |event: AgentEvent| {
1101 // Forward to tokio channel (non-blocking)
1102 let _ = tx.try_send(event.clone());
1103
1104 // Fan out to SDK-side observability handlers
1105 // (Tracer, CostTracker, ...).
1106 for handler in dispatch_handlers.iter() {
1107 handler(event.clone());
1108 }
1109 // Propagate should_stop → external_stop on every event,
1110 // not just TurnEnd. See run_with_channel_inner for rationale.
1111 if let Some(hook) = &maybe_hook {
1112 let ctx = ShouldStopAfterTurnContext {
1113 message: match &event {
1114 AgentEvent::TurnEnd {
1115 assistant_message: oxicode_ai::Message::Assistant(a),
1116 ..
1117 } => a.clone(),
1118 _ => oxicode_ai::AssistantMessage::new(
1119 oxicode_ai::Api::OpenAiCompletions,
1120 "agent",
1121 "agent-model",
1122 ),
1123 },
1124 tool_results: match &event {
1125 AgentEvent::TurnEnd { tool_results, .. } => tool_results.clone(),
1126 _ => Vec::new(),
1127 },
1128 iteration: 0,
1129 };
1130 if hook(&ctx) {
1131 ext_stop.store(true, Ordering::SeqCst);
1132 }
1133 }
1134 })
1135 .await;
1136
1137 // _guard dropped here: clears is_running on normal exit or panic.
1138
1139 match result {
1140 Ok(_events) => {
1141 // State is already shared via the same SharedState Arc,
1142 // so self.state() will reflect all mutations.
1143 Ok(Response {
1144 content: String::new(),
1145 stop_reason: StopReason::Stop,
1146 })
1147 }
1148 Err(e) => Err(e),
1149 }
1150 });
1151
1152 Ok((rx, handle))
1153 }
1154}