Skip to main content

agent_air_runtime/agent/
core.rs

1// AgentAir - Core runtime infrastructure for LLM-powered agents
2//
3// This module provides the runtime engine without any TUI dependencies.
4// For TUI functionality, use agent-air-tui which extends this with run().
5
6use std::io;
7use std::sync::Arc;
8
9use tokio::runtime::Runtime;
10use tokio::sync::mpsc;
11use tokio_util::sync::CancellationToken;
12
13use crate::controller::{
14    ControllerEvent, ControllerInputPayload, Executable, LLMController, LLMSessionConfig, LLMTool,
15    ListSkillsTool, PermissionRegistry, ToolRegistry, UserInteractionRegistry,
16};
17use crate::skills::{SkillDiscovery, SkillDiscoveryError, SkillRegistry, SkillReloadResult};
18
19use super::config::{AgentConfig, LLMRegistry, load_config};
20use super::error::AgentError;
21use super::logger::Logger;
22use super::messages::UiMessage;
23use super::messages::channels::DEFAULT_CHANNEL_SIZE;
24use super::router::InputRouter;
25
26/// Sender for messages from frontend to controller
27pub type ToControllerTx = mpsc::Sender<ControllerInputPayload>;
28/// Receiver for messages from frontend to controller
29pub type ToControllerRx = mpsc::Receiver<ControllerInputPayload>;
30/// Sender for messages from controller to frontend
31pub type FromControllerTx = mpsc::Sender<UiMessage>;
32/// Receiver for messages from controller to frontend
33pub type FromControllerRx = mpsc::Receiver<UiMessage>;
34
35/// AgentAir - Core runtime infrastructure for LLM-powered agents.
36///
37/// AgentAir provides all the infrastructure needed for an LLM-powered agent:
38/// - Logging with tracing
39/// - LLM configuration loading
40/// - Tokio async runtime
41/// - LLMController for session management
42/// - Communication channels
43/// - User interaction and permission registries
44///
45/// This is the runtime-only version. For TUI support, use the `agent-air` crate
46/// with the `tui` feature enabled, which provides the `run()` method.
47///
48/// # Basic Usage (Headless)
49///
50/// ```ignore
51/// use agent_air_runtime::agent::{AgentConfig, AgentAir};
52///
53/// struct MyConfig;
54/// impl AgentConfig for MyConfig {
55///     fn config_path(&self) -> &str { ".myagent/config.yaml" }
56///     fn default_system_prompt(&self) -> &str { "You are helpful." }
57///     fn log_prefix(&self) -> &str { "myagent" }
58///     fn name(&self) -> &str { "MyAgent" }
59/// }
60///
61/// fn main() -> std::io::Result<()> {
62///     let mut core = AgentAir::new(&MyConfig)?;
63///     core.start_background_tasks();
64///
65///     // Get channels for custom frontend integration
66///     let tx = core.to_controller_tx();
67///     let rx = core.take_from_controller_rx();
68///
69///     // Create a session and interact programmatically
70///     let (session_id, model, _) = core.create_initial_session()?;
71///     // ... send messages and receive responses via channels
72///
73///     core.shutdown();
74///     Ok(())
75/// }
76/// ```
77pub struct AgentAir {
78    /// Logger instance - never directly accessed but must be kept alive for RAII.
79    /// Dropping this field would stop logging, so it's held for the lifetime of AgentAir.
80    #[allow(dead_code)]
81    logger: Logger,
82
83    /// Agent name for display
84    name: String,
85
86    /// Agent version for display
87    version: String,
88
89    /// Tokio runtime for async operations
90    runtime: Runtime,
91
92    /// The LLM controller
93    controller: Arc<LLMController>,
94
95    /// LLM provider registry (loaded from config)
96    llm_registry: Option<LLMRegistry>,
97
98    /// Sender for messages from frontend to controller
99    to_controller_tx: ToControllerTx,
100
101    /// Receiver for messages from frontend to controller (consumed by InputRouter)
102    to_controller_rx: Option<ToControllerRx>,
103
104    /// Sender for messages from controller to frontend (held by event handler)
105    from_controller_tx: FromControllerTx,
106
107    /// Receiver for messages from controller to frontend
108    from_controller_rx: Option<FromControllerRx>,
109
110    /// Cancellation token for graceful shutdown
111    cancel_token: CancellationToken,
112
113    /// User interaction registry for AskUserQuestions tool
114    user_interaction_registry: Arc<UserInteractionRegistry>,
115
116    /// Permission registry for AskForPermissions tool
117    permission_registry: Arc<PermissionRegistry>,
118
119    /// Tool definitions to register on sessions
120    tool_definitions: Vec<LLMTool>,
121
122    /// Error message shown when user submits but no session exists
123    error_no_session: Option<String>,
124
125    /// Skill registry for Agent Skills support
126    skill_registry: Arc<SkillRegistry>,
127
128    /// Skill discovery paths
129    skill_discovery: SkillDiscovery,
130}
131
132impl AgentAir {
133    /// Create a new AgentAir with the given configuration.
134    ///
135    /// This initializes:
136    /// - Logging infrastructure
137    /// - LLM configuration from config file or environment
138    /// - Tokio runtime
139    /// - Communication channels
140    /// - LLMController
141    /// - User interaction and permission registries
142    pub fn new<C: AgentConfig>(config: &C) -> io::Result<Self> {
143        let logger = Logger::new(config.log_prefix())?;
144        tracing::info!("{} agent initialized", config.name());
145
146        // Load LLM configuration
147        let llm_registry = load_config(config);
148        if llm_registry.is_empty() {
149            tracing::warn!(
150                "No LLM providers configured. Set ANTHROPIC_API_KEY or create ~/{}",
151                config.config_path()
152            );
153        } else {
154            tracing::info!(
155                "Loaded {} LLM provider(s): {:?}",
156                llm_registry.providers().len(),
157                llm_registry.providers()
158            );
159        }
160
161        // Create tokio runtime for async operations
162        let runtime = Runtime::new()
163            .map_err(|e| io::Error::other(format!("Failed to create runtime: {}", e)))?;
164
165        // Get channel buffer size from config (or use default)
166        let channel_size = config.channel_buffer_size().unwrap_or(DEFAULT_CHANNEL_SIZE);
167        tracing::debug!("Using channel buffer size: {}", channel_size);
168
169        // Create communication channels
170        let (to_controller_tx, to_controller_rx) =
171            mpsc::channel::<ControllerInputPayload>(channel_size);
172        let (from_controller_tx, from_controller_rx) = mpsc::channel::<UiMessage>(channel_size);
173
174        // Create channel for user interaction events
175        let (interaction_event_tx, mut interaction_event_rx) =
176            mpsc::channel::<ControllerEvent>(channel_size);
177
178        // Create the user interaction registry
179        let user_interaction_registry =
180            Arc::new(UserInteractionRegistry::new(interaction_event_tx));
181
182        // Spawn a task to forward user interaction events to the UI channel
183        // Uses blocking send for backpressure
184        let ui_tx_for_interactions = from_controller_tx.clone();
185        runtime.spawn(async move {
186            while let Some(event) = interaction_event_rx.recv().await {
187                let msg = convert_controller_event_to_ui_message(event);
188                if let Err(e) = ui_tx_for_interactions.send(msg).await {
189                    tracing::warn!("Failed to send user interaction event to UI: {}", e);
190                }
191            }
192        });
193
194        // Create channel for permission events
195        let (permission_event_tx, mut permission_event_rx) =
196            mpsc::channel::<ControllerEvent>(channel_size);
197
198        // Create the permission registry
199        let permission_registry = Arc::new(PermissionRegistry::new(permission_event_tx));
200
201        // Spawn a task to forward permission events to the UI channel
202        // Uses blocking send for backpressure
203        let ui_tx_for_permissions = from_controller_tx.clone();
204        runtime.spawn(async move {
205            while let Some(event) = permission_event_rx.recv().await {
206                let msg = convert_controller_event_to_ui_message(event);
207                if let Err(e) = ui_tx_for_permissions.send(msg).await {
208                    tracing::warn!("Failed to send permission event to UI: {}", e);
209                }
210            }
211        });
212
213        // Create the controller with UI channel for direct event forwarding
214        // The controller will use backpressure: when UI channel is full, it stops
215        // reading from LLM, which backs up the from_llm channel, which blocks the
216        // session, which slows down network consumption.
217        let controller = Arc::new(LLMController::new(
218            permission_registry.clone(),
219            Some(from_controller_tx.clone()),
220            Some(channel_size),
221        ));
222        let cancel_token = CancellationToken::new();
223
224        Ok(Self {
225            logger,
226            name: config.name().to_string(),
227            version: "0.1.0".to_string(),
228            runtime,
229            controller,
230            llm_registry: Some(llm_registry),
231            to_controller_tx,
232            to_controller_rx: Some(to_controller_rx),
233            from_controller_tx,
234            from_controller_rx: Some(from_controller_rx),
235            cancel_token,
236            user_interaction_registry,
237            permission_registry,
238            tool_definitions: Vec::new(),
239            error_no_session: None,
240            skill_registry: Arc::new(SkillRegistry::new()),
241            skill_discovery: SkillDiscovery::new(),
242        })
243    }
244
245    /// Create a new AgentAir with simple configuration parameters.
246    ///
247    /// This is a convenience constructor for quick agent setup without
248    /// defining a custom config struct.
249    ///
250    /// # Arguments
251    /// * `name` - Agent name for display (e.g., "my-agent")
252    /// * `config_path` - Path to config file (e.g., "~/.config/my-agent/config.yaml")
253    /// * `system_prompt` - Default system prompt for the agent
254    ///
255    /// # Example
256    ///
257    /// ```ignore
258    /// use agent_air::agent::AgentAir;
259    /// use agent_air::tui::AgentAirExt;
260    ///
261    /// AgentAir::with_config("my-agent", "~/.config/my-agent/config.yaml", "You are helpful.")?
262    ///     .into_tui()
263    ///     .run()
264    /// ```
265    pub fn with_config(
266        name: impl Into<String>,
267        config_path: impl Into<String>,
268        system_prompt: impl Into<String>,
269    ) -> io::Result<Self> {
270        let config = super::config::SimpleConfig::new(name, config_path, system_prompt);
271        Self::new(&config)
272    }
273
274    /// Set the error message shown when user submits but no session exists.
275    ///
276    /// This overrides the default message "No active session. Use /new-session to create one."
277    ///
278    /// # Example
279    ///
280    /// ```ignore
281    /// agent.set_error_no_session("No configuration found in ~/.myagent/config.yaml");
282    /// ```
283    pub fn set_error_no_session(&mut self, message: impl Into<String>) -> &mut Self {
284        self.error_no_session = Some(message.into());
285        self
286    }
287
288    /// Get the error message for no session, if set.
289    pub fn error_no_session(&self) -> Option<&str> {
290        self.error_no_session.as_deref()
291    }
292
293    /// Set the agent version for display.
294    pub fn set_version(&mut self, version: impl Into<String>) {
295        self.version = version.into();
296    }
297
298    /// Get the agent version.
299    pub fn version(&self) -> &str {
300        &self.version
301    }
302
303    /// Load environment context into the system prompt.
304    ///
305    /// This adds information about the current execution environment to
306    /// all LLM session prompts:
307    /// - Current working directory
308    /// - Platform (darwin, linux, windows)
309    /// - OS version
310    /// - Today's date
311    ///
312    /// The context is wrapped in `<env>` tags and appended to the system prompt.
313    ///
314    /// # Example
315    ///
316    /// ```ignore
317    /// let mut core = AgentAir::new(&config)?;
318    /// core.load_environment_context();
319    /// ```
320    pub fn load_environment_context(&mut self) -> &mut Self {
321        if let Some(registry) = self.llm_registry.take() {
322            self.llm_registry = Some(registry.with_environment_context());
323            tracing::info!("Environment context loaded into system prompt");
324        }
325        self
326    }
327
328    /// Register tools with the agent.
329    ///
330    /// The callback receives references to the tool registry and interaction registries,
331    /// and should return the tool definitions to register.
332    ///
333    /// # Example
334    ///
335    /// ```ignore
336    /// core.register_tools(|registry, user_reg, perm_reg| {
337    ///     tools::register_all_tools(registry, user_reg, perm_reg)
338    /// })?;
339    /// ```
340    pub fn register_tools<F>(&mut self, f: F) -> Result<(), AgentError>
341    where
342        F: FnOnce(
343            &Arc<ToolRegistry>,
344            &Arc<UserInteractionRegistry>,
345            &Arc<PermissionRegistry>,
346        ) -> Result<Vec<LLMTool>, String>,
347    {
348        let tool_defs = f(
349            self.controller.tool_registry(),
350            &self.user_interaction_registry,
351            &self.permission_registry,
352        )
353        .map_err(AgentError::ToolRegistration)?;
354        self.tool_definitions = tool_defs;
355        Ok(())
356    }
357
358    /// Register tools with the agent using an async function.
359    ///
360    /// Similar to `register_tools`, but accepts an async closure. The closure
361    /// is executed using the agent's tokio runtime via `block_on`.
362    ///
363    /// # Example
364    ///
365    /// ```ignore
366    /// core.register_tools_async(|registry, user_reg, perm_reg| async move {
367    ///     tools::register_all_tools(&registry, user_reg, perm_reg).await
368    /// })?;
369    /// ```
370    pub fn register_tools_async<F, Fut>(&mut self, f: F) -> Result<(), AgentError>
371    where
372        F: FnOnce(Arc<ToolRegistry>, Arc<UserInteractionRegistry>, Arc<PermissionRegistry>) -> Fut,
373        Fut: std::future::Future<Output = Result<Vec<LLMTool>, String>>,
374    {
375        let tool_defs = self
376            .runtime
377            .block_on(f(
378                self.controller.tool_registry().clone(),
379                self.user_interaction_registry.clone(),
380                self.permission_registry.clone(),
381            ))
382            .map_err(AgentError::ToolRegistration)?;
383        self.tool_definitions = tool_defs;
384        Ok(())
385    }
386
387    /// Start the controller and input router as background tasks.
388    ///
389    /// This must be called before sending messages or creating sessions.
390    /// After calling this, the controller is running and ready to accept input.
391    pub fn start_background_tasks(&mut self) {
392        tracing::info!("{} starting background tasks", self.name);
393
394        // Start the controller event loop in a background task
395        let controller = self.controller.clone();
396        self.runtime.spawn(async move {
397            controller.start().await;
398        });
399        tracing::info!("Controller started");
400
401        // Start the input router in a background task
402        if let Some(to_controller_rx) = self.to_controller_rx.take() {
403            let router = InputRouter::new(
404                self.controller.clone(),
405                to_controller_rx,
406                self.cancel_token.clone(),
407            );
408            self.runtime.spawn(async move {
409                router.run().await;
410            });
411            tracing::info!("InputRouter started");
412        }
413    }
414
415    /// Internal helper to create a session and configure tools.
416    async fn create_session_internal(
417        controller: &Arc<LLMController>,
418        mut config: LLMSessionConfig,
419        tools: &[LLMTool],
420        skill_registry: &Arc<SkillRegistry>,
421    ) -> Result<i64, crate::client::error::LlmError> {
422        // Inject skills XML into system prompt
423        let skills_xml = skill_registry.to_prompt_xml();
424        if !skills_xml.is_empty() {
425            config.system_prompt = Some(match config.system_prompt {
426                Some(prompt) => format!("{}\n\n{}", prompt, skills_xml),
427                None => skills_xml,
428            });
429        }
430
431        let id = controller.create_session(config).await?;
432
433        // Set tools on the session after creation
434        if !tools.is_empty()
435            && let Some(session) = controller.get_session(id).await
436        {
437            session.set_tools(tools.to_vec()).await;
438        }
439
440        Ok(id)
441    }
442
443    /// Create an initial session using the default LLM provider.
444    ///
445    /// Returns the session ID, model name, and context limit.
446    pub fn create_initial_session(&mut self) -> Result<(i64, String, i32), AgentError> {
447        let registry = self
448            .llm_registry
449            .as_ref()
450            .ok_or_else(|| AgentError::NoConfiguration("No LLM registry available".to_string()))?;
451
452        let config = registry.get_default().ok_or_else(|| {
453            AgentError::NoConfiguration("No default LLM provider configured".to_string())
454        })?;
455
456        let model = config.model.clone();
457        let context_limit = config.context_limit;
458
459        let controller = self.controller.clone();
460        let tool_definitions = self.tool_definitions.clone();
461        let skill_registry = self.skill_registry.clone();
462
463        let session_id = self.runtime.block_on(Self::create_session_internal(
464            &controller,
465            config.clone(),
466            &tool_definitions,
467            &skill_registry,
468        ))?;
469
470        tracing::info!(
471            session_id = session_id,
472            model = %model,
473            "Created initial session"
474        );
475
476        Ok((session_id, model, context_limit))
477    }
478
479    /// Create a session with the given configuration.
480    ///
481    /// Returns the session ID or an error.
482    pub fn create_session(&self, config: LLMSessionConfig) -> Result<i64, AgentError> {
483        let controller = self.controller.clone();
484        let tool_definitions = self.tool_definitions.clone();
485        let skill_registry = self.skill_registry.clone();
486
487        self.runtime
488            .block_on(Self::create_session_internal(
489                &controller,
490                config,
491                &tool_definitions,
492                &skill_registry,
493            ))
494            .map_err(AgentError::from)
495    }
496
497    /// Signal shutdown to all background tasks and the controller.
498    pub fn shutdown(&self) {
499        tracing::info!("{} shutting down", self.name);
500        self.cancel_token.cancel();
501
502        let controller = self.controller.clone();
503        self.runtime.block_on(async move {
504            controller.shutdown().await;
505        });
506
507        tracing::info!("{} shutdown complete", self.name);
508    }
509
510    // ---- Custom Frontend Support ----
511
512    /// Run the agent with a custom frontend.
513    ///
514    /// This is the primary entry point for custom frontends. It:
515    /// 1. Starts background tasks (controller, input router)
516    /// 2. Wires the event sink to receive engine events
517    /// 3. Wires the input source to provide user input
518    /// 4. Applies the permission policy
519    /// 5. Runs until the input source closes
520    ///
521    /// # Arguments
522    ///
523    /// * `event_sink` - Receives events from the engine
524    /// * `input_source` - Provides input to the engine
525    /// * `permission_policy` - Handles permission requests
526    ///
527    /// # Example: Headless with Auto-Approve
528    ///
529    /// ```ignore
530    /// use agent_air_runtime::agent::{
531    ///     AgentAir, AutoApprovePolicy, StdoutEventSink, ChannelInputSource
532    /// };
533    ///
534    /// let mut agent = AgentAir::with_config(
535    ///     "my-agent",
536    ///     "~/.config/my-agent/config.yaml",
537    ///     "You are helpful."
538    /// )?;
539    ///
540    /// // Create input channel
541    /// let (input_tx, input_source) = ChannelInputSource::channel(100);
542    ///
543    /// // Run with custom frontend (blocks until input_tx is dropped)
544    /// agent.run_with_frontend(
545    ///     StdoutEventSink::new(),
546    ///     input_source,
547    ///     AutoApprovePolicy::new(),
548    /// )?;
549    /// ```
550    pub fn run_with_frontend<E, I, P>(
551        &mut self,
552        event_sink: E,
553        mut input_source: I,
554        permission_policy: P,
555    ) -> io::Result<()>
556    where
557        E: super::interface::EventSink,
558        I: super::interface::InputSource,
559        P: super::interface::PermissionPolicy,
560    {
561        use super::interface::PolicyDecision;
562        use crate::permissions::{BatchPermissionResponse, PermissionPanelResponse};
563        use std::sync::Arc;
564
565        tracing::info!("{} starting with custom frontend", self.name);
566
567        // Wrap sink in Arc for sharing with event forwarder
568        let sink = Arc::new(event_sink);
569        let policy = Arc::new(permission_policy);
570
571        // Start background tasks (controller, but not the default input router)
572        // We'll handle input ourselves
573        let controller = self.controller.clone();
574        self.runtime.spawn(async move {
575            controller.start().await;
576        });
577        tracing::info!("Controller started");
578
579        // Set up event forwarding from controller to custom sink
580        // Take the from_controller_rx if available
581        if let Some(mut from_controller_rx) = self.from_controller_rx.take() {
582            let sink_clone = sink.clone();
583            let policy_clone = policy.clone();
584            let permission_registry = self.permission_registry.clone();
585            let user_interaction_registry = self.user_interaction_registry.clone();
586
587            self.runtime.spawn(async move {
588                while let Some(event) = from_controller_rx.recv().await {
589                    // Check if this is a permission request that should be handled by policy
590                    match &event {
591                        UiMessage::PermissionRequired {
592                            tool_use_id,
593                            request,
594                            ..
595                        } => {
596                            match policy_clone.decide(request) {
597                                PolicyDecision::AskUser => {
598                                    // Fall through to forward to sink
599                                }
600                                decision => {
601                                    let response = match decision {
602                                        PolicyDecision::Allow => PermissionPanelResponse {
603                                            granted: true,
604                                            grant: None,
605                                            message: None,
606                                        },
607                                        PolicyDecision::AllowWithGrant(grant) => {
608                                            PermissionPanelResponse {
609                                                granted: true,
610                                                grant: Some(grant),
611                                                message: None,
612                                            }
613                                        }
614                                        PolicyDecision::Deny { reason } => {
615                                            PermissionPanelResponse {
616                                                granted: false,
617                                                grant: None,
618                                                message: reason,
619                                            }
620                                        }
621                                        PolicyDecision::AskUser => unreachable!(),
622                                    };
623                                    if let Err(e) = permission_registry
624                                        .respond_to_request(tool_use_id, response)
625                                        .await
626                                    {
627                                        tracing::warn!(
628                                            "Failed to respond to permission request: {}",
629                                            e
630                                        );
631                                    }
632                                    continue; // Don't forward to sink
633                                }
634                            }
635                        }
636                        UiMessage::BatchPermissionRequired { batch, .. } => {
637                            // Check if policy handles all requests in the batch
638                            let mut all_handled = true;
639                            let mut approved_grants = Vec::new();
640                            let mut denied_ids = Vec::new();
641
642                            for request in &batch.requests {
643                                match policy_clone.decide(request) {
644                                    PolicyDecision::Allow => {
645                                        // No grant to add, but approved
646                                    }
647                                    PolicyDecision::AllowWithGrant(grant) => {
648                                        approved_grants.push(grant);
649                                    }
650                                    PolicyDecision::Deny { .. } => {
651                                        denied_ids.push(request.id.clone());
652                                    }
653                                    PolicyDecision::AskUser => {
654                                        all_handled = false;
655                                        break;
656                                    }
657                                }
658                            }
659
660                            if all_handled {
661                                // Respond to batch with policy decisions
662                                let response = if denied_ids.is_empty() {
663                                    BatchPermissionResponse::all_granted(
664                                        &batch.batch_id,
665                                        approved_grants,
666                                    )
667                                } else {
668                                    BatchPermissionResponse::all_denied(&batch.batch_id, denied_ids)
669                                };
670                                if let Err(e) = permission_registry
671                                    .respond_to_batch(&batch.batch_id, response)
672                                    .await
673                                {
674                                    tracing::warn!(
675                                        "Failed to respond to batch permission request: {}",
676                                        e
677                                    );
678                                }
679                                continue; // Don't forward to sink
680                            }
681                            // Fall through to forward to sink if any request needs user input
682                        }
683                        UiMessage::UserInteractionRequired { tool_use_id, .. } => {
684                            if !policy_clone.supports_interaction() {
685                                // Headless mode - auto-cancel the interaction
686                                if let Err(e) = user_interaction_registry.cancel(tool_use_id).await
687                                {
688                                    tracing::warn!("Failed to cancel user interaction: {}", e);
689                                }
690                                tracing::debug!("Auto-cancelled user interaction in headless mode");
691                                continue; // Don't forward to sink
692                            }
693                            // Fall through to forward to sink for interactive policies
694                        }
695                        _ => {}
696                    }
697
698                    // Forward event to sink
699                    if let Err(e) = sink_clone.send(event) {
700                        tracing::warn!("Failed to send event to sink: {}", e);
701                    }
702                }
703            });
704        }
705
706        // Create initial session if configured
707        match self.create_initial_session() {
708            Ok((session_id, model, _)) => {
709                tracing::info!(session_id, model = %model, "Created initial session");
710            }
711            Err(e) => {
712                tracing::warn!(error = %e, "No initial session created");
713            }
714        }
715
716        // Run input loop - forward input from source to controller
717        let to_controller_tx = self.to_controller_tx.clone();
718        self.runtime.block_on(async {
719            while let Some(input) = input_source.recv().await {
720                if let Err(e) = to_controller_tx.send(input).await {
721                    tracing::error!(error = %e, "Failed to send input to controller");
722                    break;
723                }
724            }
725        });
726
727        // Shutdown
728        self.shutdown();
729        tracing::info!("{} stopped", self.name);
730
731        Ok(())
732    }
733
734    // ---- Accessors ----
735
736    /// Returns a sender for sending messages to the controller.
737    pub fn to_controller_tx(&self) -> ToControllerTx {
738        self.to_controller_tx.clone()
739    }
740
741    /// Takes the receiver for messages from the controller (can only be called once).
742    pub fn take_from_controller_rx(&mut self) -> Option<FromControllerRx> {
743        self.from_controller_rx.take()
744    }
745
746    /// Returns a reference to the controller.
747    pub fn controller(&self) -> &Arc<LLMController> {
748        &self.controller
749    }
750
751    /// Returns a reference to the runtime.
752    pub fn runtime(&self) -> &Runtime {
753        &self.runtime
754    }
755
756    /// Returns a handle to the runtime.
757    pub fn runtime_handle(&self) -> tokio::runtime::Handle {
758        self.runtime.handle().clone()
759    }
760
761    /// Returns a reference to the user interaction registry.
762    pub fn user_interaction_registry(&self) -> &Arc<UserInteractionRegistry> {
763        &self.user_interaction_registry
764    }
765
766    /// Returns a reference to the permission registry.
767    pub fn permission_registry(&self) -> &Arc<PermissionRegistry> {
768        &self.permission_registry
769    }
770
771    /// Removes a session and cleans up all associated resources.
772    ///
773    /// This is the recommended way to remove a session as it orchestrates cleanup across:
774    /// - The LLM session manager (terminates the session)
775    /// - The permission registry (cancels pending permission requests)
776    /// - The user interaction registry (cancels pending user questions)
777    /// - The tool registry (cleans up per-session state in tools)
778    ///
779    /// # Arguments
780    /// * `session_id` - The ID of the session to remove
781    ///
782    /// # Returns
783    /// true if the session was found and removed, false if session didn't exist
784    pub async fn remove_session(&self, session_id: i64) -> bool {
785        // Remove from controller's session manager
786        let removed = self.controller.remove_session(session_id).await;
787
788        // Clean up pending permission requests for this session
789        self.permission_registry.cancel_session(session_id).await;
790
791        // Clean up pending user interactions for this session
792        self.user_interaction_registry
793            .cancel_session(session_id)
794            .await;
795
796        // Clean up per-session state in tools (e.g., bash working directories)
797        self.controller
798            .tool_registry()
799            .cleanup_session(session_id)
800            .await;
801
802        if removed {
803            tracing::info!(session_id, "Session removed with full cleanup");
804        }
805
806        removed
807    }
808
809    /// Returns a reference to the LLM registry.
810    pub fn llm_registry(&self) -> Option<&LLMRegistry> {
811        self.llm_registry.as_ref()
812    }
813
814    /// Takes the LLM registry (can only be called once).
815    pub fn take_llm_registry(&mut self) -> Option<LLMRegistry> {
816        self.llm_registry.take()
817    }
818
819    /// Returns the cancellation token.
820    pub fn cancel_token(&self) -> CancellationToken {
821        self.cancel_token.clone()
822    }
823
824    /// Returns the agent name.
825    pub fn name(&self) -> &str {
826        &self.name
827    }
828
829    /// Returns a clone of the UI message sender.
830    ///
831    /// This can be used to send messages to the frontend's event loop.
832    pub fn from_controller_tx(&self) -> FromControllerTx {
833        self.from_controller_tx.clone()
834    }
835
836    /// Returns a reference to the tool definitions.
837    pub fn tool_definitions(&self) -> &[LLMTool] {
838        &self.tool_definitions
839    }
840
841    // ---- Skills ----
842
843    /// Returns a reference to the skill registry.
844    pub fn skill_registry(&self) -> &Arc<SkillRegistry> {
845        &self.skill_registry
846    }
847
848    /// Register the ListSkillsTool, allowing the LLM to discover available skills.
849    ///
850    /// This registers the `list_skills` tool with the tool registry and adds its
851    /// definition to the tool list. Call this after `register_tools()` if you want
852    /// the LLM to be able to query available skills.
853    ///
854    /// Returns the LLM tool definition that was added.
855    pub fn register_list_skills_tool(&mut self) -> Result<LLMTool, AgentError> {
856        let tool = ListSkillsTool::new(self.skill_registry.clone());
857        let llm_tool = tool.to_llm_tool();
858
859        self.runtime
860            .block_on(async {
861                self.controller
862                    .tool_registry()
863                    .register(Arc::new(tool))
864                    .await
865            })
866            .map_err(|e| AgentError::ToolRegistration(e.to_string()))?;
867
868        self.tool_definitions.push(llm_tool.clone());
869        tracing::info!("Registered list_skills tool");
870
871        Ok(llm_tool)
872    }
873
874    /// Add a custom skill search path.
875    ///
876    /// Skills are discovered from directories containing SKILL.md files.
877    /// By default, `$PWD/.skills/` and `~/.agent-air/skills/` are searched.
878    pub fn add_skill_path(&mut self, path: std::path::PathBuf) -> &mut Self {
879        self.skill_discovery.add_path(path);
880        self
881    }
882
883    /// Load skills from configured directories.
884    ///
885    /// This scans all configured skill paths and registers discovered skills
886    /// in the skill registry. Call this after configuring skill paths.
887    ///
888    /// Returns the number of skills loaded and any errors encountered.
889    pub fn load_skills(&mut self) -> (usize, Vec<SkillDiscoveryError>) {
890        let results = self.skill_discovery.discover();
891        self.register_discovered_skills(results)
892    }
893
894    /// Load skills from specific paths (one-shot, doesn't modify default discovery).
895    ///
896    /// This creates a temporary discovery instance with only the provided paths,
897    /// loads skills from them, and registers them in the skill registry.
898    /// Unlike `add_skill_path()` + `load_skills()`, this doesn't affect the
899    /// default discovery paths used by `reload_skills()`.
900    ///
901    /// Returns the number of skills loaded and any errors encountered.
902    pub fn load_skills_from(
903        &self,
904        paths: Vec<std::path::PathBuf>,
905    ) -> (usize, Vec<SkillDiscoveryError>) {
906        let mut discovery = SkillDiscovery::empty();
907        for path in paths {
908            discovery.add_path(path);
909        }
910
911        let results = discovery.discover();
912        self.register_discovered_skills(results)
913    }
914
915    /// Helper to register discovered skills and collect errors.
916    ///
917    /// Logs a warning if a skill with the same name already exists (duplicate detection).
918    fn register_discovered_skills(
919        &self,
920        results: Vec<Result<crate::skills::Skill, SkillDiscoveryError>>,
921    ) -> (usize, Vec<SkillDiscoveryError>) {
922        let mut errors = Vec::new();
923        let mut count = 0;
924
925        for result in results {
926            match result {
927                Ok(skill) => {
928                    let skill_name = skill.metadata.name.clone();
929                    let skill_path = skill.path.clone();
930                    let replaced = self.skill_registry.register(skill);
931
932                    if let Some(old_skill) = replaced {
933                        tracing::warn!(
934                            skill_name = %skill_name,
935                            new_path = %skill_path.display(),
936                            old_path = %old_skill.path.display(),
937                            "Duplicate skill name detected - replaced existing skill"
938                        );
939                    }
940
941                    tracing::info!(
942                        skill_name = %skill_name,
943                        skill_path = %skill_path.display(),
944                        "Loaded skill"
945                    );
946                    count += 1;
947                }
948                Err(e) => {
949                    tracing::warn!(
950                        path = %e.path.display(),
951                        error = %e.message,
952                        "Failed to load skill"
953                    );
954                    errors.push(e);
955                }
956            }
957        }
958
959        tracing::info!("Loaded {} skill(s)", count);
960        (count, errors)
961    }
962
963    /// Reload skills from configured directories.
964    ///
965    /// This re-scans all configured skill paths and updates the registry:
966    /// - New skills are added
967    /// - Removed skills are unregistered
968    /// - Existing skills are re-registered (silently updated)
969    ///
970    /// Returns information about what changed (added/removed only).
971    pub fn reload_skills(&mut self) -> SkillReloadResult {
972        let current_names: std::collections::HashSet<String> =
973            self.skill_registry.names().into_iter().collect();
974
975        let results = self.skill_discovery.discover();
976        let mut discovered_names = std::collections::HashSet::new();
977        let mut result = SkillReloadResult::default();
978
979        // Process discovered skills
980        for discovery_result in results {
981            match discovery_result {
982                Ok(skill) => {
983                    let name = skill.metadata.name.clone();
984                    discovered_names.insert(name.clone());
985
986                    if !current_names.contains(&name) {
987                        tracing::info!(skill_name = %name, "Added new skill");
988                        result.added.push(name);
989                    }
990                    self.skill_registry.register(skill);
991                }
992                Err(e) => {
993                    tracing::warn!(
994                        path = %e.path.display(),
995                        error = %e.message,
996                        "Failed to load skill during reload"
997                    );
998                    result.errors.push(e);
999                }
1000            }
1001        }
1002
1003        // Find and remove skills that no longer exist
1004        for name in &current_names {
1005            if !discovered_names.contains(name) {
1006                tracing::info!(skill_name = %name, "Removed skill");
1007                self.skill_registry.unregister(name);
1008                result.removed.push(name.clone());
1009            }
1010        }
1011
1012        tracing::info!(
1013            added = result.added.len(),
1014            removed = result.removed.len(),
1015            errors = result.errors.len(),
1016            "Skills reloaded"
1017        );
1018
1019        result
1020    }
1021
1022    /// Get skills XML for injection into system prompts.
1023    ///
1024    /// Returns an XML string listing all available skills that can be
1025    /// included in the system prompt to inform the LLM about available capabilities.
1026    pub fn skills_prompt_xml(&self) -> String {
1027        self.skill_registry.to_prompt_xml()
1028    }
1029
1030    /// Refresh a session's system prompt with current skills.
1031    ///
1032    /// This updates the session's system prompt to include the current
1033    /// `<available_skills>` XML from the skill registry.
1034    ///
1035    /// Note: This appends the skills XML to the existing system prompt.
1036    /// If skills were previously loaded, this may result in duplicate entries.
1037    pub async fn refresh_session_skills(&self, session_id: i64) -> Result<(), AgentError> {
1038        let skills_xml = self.skills_prompt_xml();
1039        if skills_xml.is_empty() {
1040            return Ok(());
1041        }
1042
1043        let session = self
1044            .controller
1045            .get_session(session_id)
1046            .await
1047            .ok_or(AgentError::SessionNotFound(session_id))?;
1048
1049        let current_prompt = session.system_prompt().await.unwrap_or_default();
1050
1051        // Check if skills are already in the prompt to avoid duplicates
1052        let new_prompt = if current_prompt.contains("<available_skills>") {
1053            // Replace existing skills section
1054            replace_skills_section(&current_prompt, &skills_xml)
1055        } else if current_prompt.is_empty() {
1056            // No existing prompt, just use skills
1057            skills_xml
1058        } else {
1059            // Append skills section
1060            format!("{}\n\n{}", current_prompt, skills_xml)
1061        };
1062
1063        session.set_system_prompt(new_prompt).await;
1064        tracing::debug!(session_id, "Refreshed session skills");
1065        Ok(())
1066    }
1067}
1068
1069/// Replace the <available_skills> section in a system prompt.
1070fn replace_skills_section(prompt: &str, new_skills_xml: &str) -> String {
1071    if let Some(start) = prompt.find("<available_skills>")
1072        && let Some(end) = prompt.find("</available_skills>")
1073    {
1074        let end = end + "</available_skills>".len();
1075        let mut result = String::with_capacity(prompt.len());
1076        result.push_str(&prompt[..start]);
1077        result.push_str(new_skills_xml);
1078        result.push_str(&prompt[end..]);
1079        return result;
1080    }
1081    // Fallback: just append
1082    format!("{}\n\n{}", prompt, new_skills_xml)
1083}
1084
1085/// Converts a ControllerEvent to a UiMessage for the frontend.
1086///
1087/// This function maps the internal controller events to UI-friendly messages
1088/// that can be displayed in any frontend (TUI, web, etc.).
1089///
1090/// # Architecture Note
1091///
1092/// This function serves as the **intentional integration point** between the
1093/// controller layer (`ControllerEvent`) and the UI layer (`UiMessage`). It is
1094/// defined in the agent module because:
1095/// 1. The agent orchestrates both controller and UI components
1096/// 2. `UiMessage` is an agent-layer type consumed by frontends
1097/// 3. The agent owns the responsibility of bridging these layers
1098///
1099/// Both `LLMController::send_to_ui()` and `AgentAir` initialization use this
1100/// function to translate controller events into UI-displayable messages.
1101pub fn convert_controller_event_to_ui_message(event: ControllerEvent) -> UiMessage {
1102    match event {
1103        ControllerEvent::StreamStart { session_id, .. } => {
1104            // Silent - don't display stream start messages
1105            UiMessage::System {
1106                session_id,
1107                message: String::new(),
1108            }
1109        }
1110        ControllerEvent::TextChunk {
1111            session_id,
1112            text,
1113            turn_id,
1114        } => UiMessage::TextChunk {
1115            session_id,
1116            turn_id,
1117            text,
1118            input_tokens: 0,
1119            output_tokens: 0,
1120        },
1121        ControllerEvent::ToolUseStart {
1122            session_id,
1123            tool_name,
1124            turn_id,
1125            ..
1126        } => UiMessage::Display {
1127            session_id,
1128            turn_id,
1129            message: format!("Executing tool: {}", tool_name),
1130        },
1131        ControllerEvent::ToolUse {
1132            session_id,
1133            tool,
1134            display_name,
1135            display_title,
1136            turn_id,
1137        } => UiMessage::ToolExecuting {
1138            session_id,
1139            turn_id,
1140            tool_use_id: tool.id.clone(),
1141            display_name: display_name.unwrap_or_else(|| tool.name.clone()),
1142            display_title: display_title.unwrap_or_default(),
1143        },
1144        ControllerEvent::Complete {
1145            session_id,
1146            turn_id,
1147            stop_reason,
1148        } => UiMessage::Complete {
1149            session_id,
1150            turn_id,
1151            input_tokens: 0,
1152            output_tokens: 0,
1153            stop_reason,
1154        },
1155        ControllerEvent::Error {
1156            session_id,
1157            error,
1158            turn_id,
1159        } => UiMessage::Error {
1160            session_id,
1161            turn_id,
1162            error,
1163        },
1164        ControllerEvent::TokenUpdate {
1165            session_id,
1166            input_tokens,
1167            output_tokens,
1168            context_limit,
1169        } => UiMessage::TokenUpdate {
1170            session_id,
1171            turn_id: None,
1172            input_tokens,
1173            output_tokens,
1174            context_limit,
1175        },
1176        ControllerEvent::ToolResult {
1177            session_id,
1178            tool_use_id,
1179            status,
1180            error,
1181            turn_id,
1182            ..
1183        } => UiMessage::ToolCompleted {
1184            session_id,
1185            turn_id,
1186            tool_use_id,
1187            status,
1188            error,
1189        },
1190        ControllerEvent::CommandComplete {
1191            session_id,
1192            command,
1193            success,
1194            message,
1195        } => UiMessage::CommandComplete {
1196            session_id,
1197            command,
1198            success,
1199            message,
1200        },
1201        ControllerEvent::UserInteractionRequired {
1202            session_id,
1203            tool_use_id,
1204            request,
1205            turn_id,
1206        } => UiMessage::UserInteractionRequired {
1207            session_id,
1208            tool_use_id,
1209            request,
1210            turn_id,
1211        },
1212        ControllerEvent::PermissionRequired {
1213            session_id,
1214            tool_use_id,
1215            request,
1216            turn_id,
1217        } => UiMessage::PermissionRequired {
1218            session_id,
1219            tool_use_id,
1220            request,
1221            turn_id,
1222        },
1223        ControllerEvent::BatchPermissionRequired {
1224            session_id,
1225            batch,
1226            turn_id,
1227        } => UiMessage::BatchPermissionRequired {
1228            session_id,
1229            batch,
1230            turn_id,
1231        },
1232    }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use crate::controller::TurnId;
1239
1240    #[test]
1241    fn test_convert_text_chunk_event() {
1242        let event = ControllerEvent::TextChunk {
1243            session_id: 1,
1244            text: "Hello".to_string(),
1245            turn_id: Some(TurnId::new_user_turn(1)),
1246        };
1247
1248        let msg = convert_controller_event_to_ui_message(event);
1249
1250        match msg {
1251            UiMessage::TextChunk {
1252                session_id, text, ..
1253            } => {
1254                assert_eq!(session_id, 1);
1255                assert_eq!(text, "Hello");
1256            }
1257            _ => panic!("Expected TextChunk message"),
1258        }
1259    }
1260
1261    #[test]
1262    fn test_convert_error_event() {
1263        let event = ControllerEvent::Error {
1264            session_id: 1,
1265            error: "Test error".to_string(),
1266            turn_id: None,
1267        };
1268
1269        let msg = convert_controller_event_to_ui_message(event);
1270
1271        match msg {
1272            UiMessage::Error {
1273                session_id, error, ..
1274            } => {
1275                assert_eq!(session_id, 1);
1276                assert_eq!(error, "Test error");
1277            }
1278            _ => panic!("Expected Error message"),
1279        }
1280    }
1281
1282    #[test]
1283    fn test_replace_skills_section_replaces_existing() {
1284        let prompt = "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n</available_skills>\n\nMore text.";
1285        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1286
1287        let result = replace_skills_section(prompt, new_xml);
1288
1289        assert!(result.contains("<skill>new</skill>"));
1290        assert!(!result.contains("<skill>old</skill>"));
1291        assert!(result.contains("System prompt."));
1292        assert!(result.contains("More text."));
1293    }
1294
1295    #[test]
1296    fn test_replace_skills_section_no_existing() {
1297        let prompt = "System prompt without skills.";
1298        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1299
1300        let result = replace_skills_section(prompt, new_xml);
1301
1302        // Falls back to appending
1303        assert!(result.contains("System prompt without skills."));
1304        assert!(result.contains("<skill>new</skill>"));
1305    }
1306
1307    #[test]
1308    fn test_replace_skills_section_malformed_no_closing_tag() {
1309        let prompt =
1310            "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n\nNo closing tag.";
1311        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1312
1313        let result = replace_skills_section(prompt, new_xml);
1314
1315        // Falls back to appending since closing tag is missing
1316        assert!(result.contains("<skill>old</skill>"));
1317        assert!(result.contains("<skill>new</skill>"));
1318    }
1319
1320    #[test]
1321    fn test_replace_skills_section_at_end() {
1322        let prompt =
1323            "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n</available_skills>";
1324        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1325
1326        let result = replace_skills_section(prompt, new_xml);
1327
1328        assert!(result.contains("<skill>new</skill>"));
1329        assert!(!result.contains("<skill>old</skill>"));
1330        assert!(result.starts_with("System prompt."));
1331    }
1332}