Skip to main content

agent_core_runtime/agent/
core.rs

1// AgentCore - 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-core-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,
15    LLMTool, ListSkillsTool, PermissionRegistry, ToolRegistry, UserInteractionRegistry,
16};
17use crate::skills::{SkillDiscovery, SkillDiscoveryError, SkillRegistry, SkillReloadResult};
18
19use super::config::{load_config, AgentConfig, LLMRegistry};
20use super::error::AgentError;
21use super::logger::Logger;
22use super::messages::channels::DEFAULT_CHANNEL_SIZE;
23use super::messages::UiMessage;
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/// AgentCore - Core runtime infrastructure for LLM-powered agents.
36///
37/// AgentCore 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-core` crate
46/// with the `tui` feature enabled, which provides the `run()` method.
47///
48/// # Basic Usage (Headless)
49///
50/// ```ignore
51/// use agent_core_runtime::agent::{AgentConfig, AgentCore};
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 = AgentCore::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 AgentCore {
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 AgentCore.
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 AgentCore {
133    /// Create a new AgentCore 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().map_err(|e| {
163            io::Error::new(
164                io::ErrorKind::Other,
165                format!("Failed to create runtime: {}", e),
166            )
167        })?;
168
169        // Get channel buffer size from config (or use default)
170        let channel_size = config.channel_buffer_size().unwrap_or(DEFAULT_CHANNEL_SIZE);
171        tracing::debug!("Using channel buffer size: {}", channel_size);
172
173        // Create communication channels
174        let (to_controller_tx, to_controller_rx) =
175            mpsc::channel::<ControllerInputPayload>(channel_size);
176        let (from_controller_tx, from_controller_rx) =
177            mpsc::channel::<UiMessage>(channel_size);
178
179        // Create channel for user interaction events
180        let (interaction_event_tx, mut interaction_event_rx) =
181            mpsc::channel::<ControllerEvent>(channel_size);
182
183        // Create the user interaction registry
184        let user_interaction_registry =
185            Arc::new(UserInteractionRegistry::new(interaction_event_tx));
186
187        // Spawn a task to forward user interaction events to the UI channel
188        // Uses blocking send for backpressure
189        let ui_tx_for_interactions = from_controller_tx.clone();
190        runtime.spawn(async move {
191            while let Some(event) = interaction_event_rx.recv().await {
192                let msg = convert_controller_event_to_ui_message(event);
193                if let Err(e) = ui_tx_for_interactions.send(msg).await {
194                    tracing::warn!("Failed to send user interaction event to UI: {}", e);
195                }
196            }
197        });
198
199        // Create channel for permission events
200        let (permission_event_tx, mut permission_event_rx) =
201            mpsc::channel::<ControllerEvent>(channel_size);
202
203        // Create the permission registry
204        let permission_registry = Arc::new(PermissionRegistry::new(permission_event_tx));
205
206        // Spawn a task to forward permission events to the UI channel
207        // Uses blocking send for backpressure
208        let ui_tx_for_permissions = from_controller_tx.clone();
209        runtime.spawn(async move {
210            while let Some(event) = permission_event_rx.recv().await {
211                let msg = convert_controller_event_to_ui_message(event);
212                if let Err(e) = ui_tx_for_permissions.send(msg).await {
213                    tracing::warn!("Failed to send permission event to UI: {}", e);
214                }
215            }
216        });
217
218        // Create the controller with UI channel for direct event forwarding
219        // The controller will use backpressure: when UI channel is full, it stops
220        // reading from LLM, which backs up the from_llm channel, which blocks the
221        // session, which slows down network consumption.
222        let controller = Arc::new(LLMController::new(
223            permission_registry.clone(),
224            Some(from_controller_tx.clone()),
225            Some(channel_size),
226        ));
227        let cancel_token = CancellationToken::new();
228
229        Ok(Self {
230            logger,
231            name: config.name().to_string(),
232            version: "0.1.0".to_string(),
233            runtime,
234            controller,
235            llm_registry: Some(llm_registry),
236            to_controller_tx,
237            to_controller_rx: Some(to_controller_rx),
238            from_controller_tx,
239            from_controller_rx: Some(from_controller_rx),
240            cancel_token,
241            user_interaction_registry,
242            permission_registry,
243            tool_definitions: Vec::new(),
244            error_no_session: None,
245            skill_registry: Arc::new(SkillRegistry::new()),
246            skill_discovery: SkillDiscovery::new(),
247        })
248    }
249
250    /// Set the error message shown when user submits but no session exists.
251    ///
252    /// This overrides the default message "No active session. Use /new-session to create one."
253    ///
254    /// # Example
255    ///
256    /// ```ignore
257    /// agent.set_error_no_session("No configuration found in ~/.myagent/config.yaml");
258    /// ```
259    pub fn set_error_no_session(&mut self, message: impl Into<String>) -> &mut Self {
260        self.error_no_session = Some(message.into());
261        self
262    }
263
264    /// Get the error message for no session, if set.
265    pub fn error_no_session(&self) -> Option<&str> {
266        self.error_no_session.as_deref()
267    }
268
269    /// Set the agent version for display.
270    pub fn set_version(&mut self, version: impl Into<String>) {
271        self.version = version.into();
272    }
273
274    /// Get the agent version.
275    pub fn version(&self) -> &str {
276        &self.version
277    }
278
279    /// Load environment context into the system prompt.
280    ///
281    /// This adds information about the current execution environment to
282    /// all LLM session prompts:
283    /// - Current working directory
284    /// - Platform (darwin, linux, windows)
285    /// - OS version
286    /// - Today's date
287    ///
288    /// The context is wrapped in `<env>` tags and appended to the system prompt.
289    ///
290    /// # Example
291    ///
292    /// ```ignore
293    /// let mut core = AgentCore::new(&config)?;
294    /// core.load_environment_context();
295    /// ```
296    pub fn load_environment_context(&mut self) -> &mut Self {
297        if let Some(registry) = self.llm_registry.take() {
298            self.llm_registry = Some(registry.with_environment_context());
299            tracing::info!("Environment context loaded into system prompt");
300        }
301        self
302    }
303
304    /// Register tools with the agent.
305    ///
306    /// The callback receives references to the tool registry and interaction registries,
307    /// and should return the tool definitions to register.
308    ///
309    /// # Example
310    ///
311    /// ```ignore
312    /// core.register_tools(|registry, user_reg, perm_reg| {
313    ///     tools::register_all_tools(registry, user_reg, perm_reg)
314    /// })?;
315    /// ```
316    pub fn register_tools<F>(&mut self, f: F) -> Result<(), AgentError>
317    where
318        F: FnOnce(
319            &Arc<ToolRegistry>,
320            &Arc<UserInteractionRegistry>,
321            &Arc<PermissionRegistry>,
322        ) -> Result<Vec<LLMTool>, String>,
323    {
324        let tool_defs = f(
325            self.controller.tool_registry(),
326            &self.user_interaction_registry,
327            &self.permission_registry,
328        )
329        .map_err(AgentError::ToolRegistration)?;
330        self.tool_definitions = tool_defs;
331        Ok(())
332    }
333
334    /// Register tools with the agent using an async function.
335    ///
336    /// Similar to `register_tools`, but accepts an async closure. The closure
337    /// is executed using the agent's tokio runtime via `block_on`.
338    ///
339    /// # Example
340    ///
341    /// ```ignore
342    /// core.register_tools_async(|registry, user_reg, perm_reg| async move {
343    ///     tools::register_all_tools(&registry, user_reg, perm_reg).await
344    /// })?;
345    /// ```
346    pub fn register_tools_async<F, Fut>(&mut self, f: F) -> Result<(), AgentError>
347    where
348        F: FnOnce(Arc<ToolRegistry>, Arc<UserInteractionRegistry>, Arc<PermissionRegistry>) -> Fut,
349        Fut: std::future::Future<Output = Result<Vec<LLMTool>, String>>,
350    {
351        let tool_defs = self.runtime.block_on(f(
352            self.controller.tool_registry().clone(),
353            self.user_interaction_registry.clone(),
354            self.permission_registry.clone(),
355        ))
356        .map_err(AgentError::ToolRegistration)?;
357        self.tool_definitions = tool_defs;
358        Ok(())
359    }
360
361    /// Start the controller and input router as background tasks.
362    ///
363    /// This must be called before sending messages or creating sessions.
364    /// After calling this, the controller is running and ready to accept input.
365    pub fn start_background_tasks(&mut self) {
366        tracing::info!("{} starting background tasks", self.name);
367
368        // Start the controller event loop in a background task
369        let controller = self.controller.clone();
370        self.runtime.spawn(async move {
371            controller.start().await;
372        });
373        tracing::info!("Controller started");
374
375        // Start the input router in a background task
376        if let Some(to_controller_rx) = self.to_controller_rx.take() {
377            let router = InputRouter::new(
378                self.controller.clone(),
379                to_controller_rx,
380                self.cancel_token.clone(),
381            );
382            self.runtime.spawn(async move {
383                router.run().await;
384            });
385            tracing::info!("InputRouter started");
386        }
387    }
388
389    /// Internal helper to create a session and configure tools.
390    async fn create_session_internal(
391        controller: &Arc<LLMController>,
392        mut config: LLMSessionConfig,
393        tools: &[LLMTool],
394        skill_registry: &Arc<SkillRegistry>,
395    ) -> Result<i64, crate::client::error::LlmError> {
396        // Inject skills XML into system prompt
397        let skills_xml = skill_registry.to_prompt_xml();
398        if !skills_xml.is_empty() {
399            config.system_prompt = Some(match config.system_prompt {
400                Some(prompt) => format!("{}\n\n{}", prompt, skills_xml),
401                None => skills_xml,
402            });
403        }
404
405        let id = controller.create_session(config).await?;
406
407        // Set tools on the session after creation
408        if !tools.is_empty() {
409            if let Some(session) = controller.get_session(id).await {
410                session.set_tools(tools.to_vec()).await;
411            }
412        }
413
414        Ok(id)
415    }
416
417    /// Create an initial session using the default LLM provider.
418    ///
419    /// Returns the session ID, model name, and context limit.
420    pub fn create_initial_session(&mut self) -> Result<(i64, String, i32), AgentError> {
421        let registry = self.llm_registry.as_ref().ok_or_else(|| {
422            AgentError::NoConfiguration("No LLM registry available".to_string())
423        })?;
424
425        let config = registry.get_default().ok_or_else(|| {
426            AgentError::NoConfiguration("No default LLM provider configured".to_string())
427        })?;
428
429        let model = config.model.clone();
430        let context_limit = config.context_limit;
431
432        let controller = self.controller.clone();
433        let tool_definitions = self.tool_definitions.clone();
434        let skill_registry = self.skill_registry.clone();
435
436        let session_id = self.runtime.block_on(Self::create_session_internal(
437            &controller,
438            config.clone(),
439            &tool_definitions,
440            &skill_registry,
441        ))?;
442
443        tracing::info!(
444            session_id = session_id,
445            model = %model,
446            "Created initial session"
447        );
448
449        Ok((session_id, model, context_limit))
450    }
451
452    /// Create a session with the given configuration.
453    ///
454    /// Returns the session ID or an error.
455    pub fn create_session(&self, config: LLMSessionConfig) -> Result<i64, AgentError> {
456        let controller = self.controller.clone();
457        let tool_definitions = self.tool_definitions.clone();
458        let skill_registry = self.skill_registry.clone();
459
460        self.runtime
461            .block_on(Self::create_session_internal(
462                &controller,
463                config,
464                &tool_definitions,
465                &skill_registry,
466            ))
467            .map_err(AgentError::from)
468    }
469
470    /// Signal shutdown to all background tasks and the controller.
471    pub fn shutdown(&self) {
472        tracing::info!("{} shutting down", self.name);
473        self.cancel_token.cancel();
474
475        let controller = self.controller.clone();
476        self.runtime.block_on(async move {
477            controller.shutdown().await;
478        });
479
480        tracing::info!("{} shutdown complete", self.name);
481    }
482
483    // ---- Accessors ----
484
485    /// Returns a sender for sending messages to the controller.
486    pub fn to_controller_tx(&self) -> ToControllerTx {
487        self.to_controller_tx.clone()
488    }
489
490    /// Takes the receiver for messages from the controller (can only be called once).
491    pub fn take_from_controller_rx(&mut self) -> Option<FromControllerRx> {
492        self.from_controller_rx.take()
493    }
494
495    /// Returns a reference to the controller.
496    pub fn controller(&self) -> &Arc<LLMController> {
497        &self.controller
498    }
499
500    /// Returns a reference to the runtime.
501    pub fn runtime(&self) -> &Runtime {
502        &self.runtime
503    }
504
505    /// Returns a handle to the runtime.
506    pub fn runtime_handle(&self) -> tokio::runtime::Handle {
507        self.runtime.handle().clone()
508    }
509
510    /// Returns a reference to the user interaction registry.
511    pub fn user_interaction_registry(&self) -> &Arc<UserInteractionRegistry> {
512        &self.user_interaction_registry
513    }
514
515    /// Returns a reference to the permission registry.
516    pub fn permission_registry(&self) -> &Arc<PermissionRegistry> {
517        &self.permission_registry
518    }
519
520    /// Removes a session and cleans up all associated resources.
521    ///
522    /// This is the recommended way to remove a session as it orchestrates cleanup across:
523    /// - The LLM session manager (terminates the session)
524    /// - The permission registry (cancels pending permission requests)
525    /// - The user interaction registry (cancels pending user questions)
526    /// - The tool registry (cleans up per-session state in tools)
527    ///
528    /// # Arguments
529    /// * `session_id` - The ID of the session to remove
530    ///
531    /// # Returns
532    /// true if the session was found and removed, false if session didn't exist
533    pub async fn remove_session(&self, session_id: i64) -> bool {
534        // Remove from controller's session manager
535        let removed = self.controller.remove_session(session_id).await;
536
537        // Clean up pending permission requests for this session
538        self.permission_registry.cancel_session(session_id).await;
539
540        // Clean up pending user interactions for this session
541        self.user_interaction_registry.cancel_session(session_id).await;
542
543        // Clean up per-session state in tools (e.g., bash working directories)
544        self.controller.tool_registry().cleanup_session(session_id).await;
545
546        if removed {
547            tracing::info!(session_id, "Session removed with full cleanup");
548        }
549
550        removed
551    }
552
553    /// Returns a reference to the LLM registry.
554    pub fn llm_registry(&self) -> Option<&LLMRegistry> {
555        self.llm_registry.as_ref()
556    }
557
558    /// Takes the LLM registry (can only be called once).
559    pub fn take_llm_registry(&mut self) -> Option<LLMRegistry> {
560        self.llm_registry.take()
561    }
562
563    /// Returns the cancellation token.
564    pub fn cancel_token(&self) -> CancellationToken {
565        self.cancel_token.clone()
566    }
567
568    /// Returns the agent name.
569    pub fn name(&self) -> &str {
570        &self.name
571    }
572
573    /// Returns a clone of the UI message sender.
574    ///
575    /// This can be used to send messages to the frontend's event loop.
576    pub fn from_controller_tx(&self) -> FromControllerTx {
577        self.from_controller_tx.clone()
578    }
579
580    /// Returns a reference to the tool definitions.
581    pub fn tool_definitions(&self) -> &[LLMTool] {
582        &self.tool_definitions
583    }
584
585    // ---- Skills ----
586
587    /// Returns a reference to the skill registry.
588    pub fn skill_registry(&self) -> &Arc<SkillRegistry> {
589        &self.skill_registry
590    }
591
592    /// Register the ListSkillsTool, allowing the LLM to discover available skills.
593    ///
594    /// This registers the `list_skills` tool with the tool registry and adds its
595    /// definition to the tool list. Call this after `register_tools()` if you want
596    /// the LLM to be able to query available skills.
597    ///
598    /// Returns the LLM tool definition that was added.
599    pub fn register_list_skills_tool(&mut self) -> Result<LLMTool, AgentError> {
600        let tool = ListSkillsTool::new(self.skill_registry.clone());
601        let llm_tool = tool.to_llm_tool();
602
603        self.runtime.block_on(async {
604            self.controller
605                .tool_registry()
606                .register(Arc::new(tool))
607                .await
608        }).map_err(|e| AgentError::ToolRegistration(e.to_string()))?;
609
610        self.tool_definitions.push(llm_tool.clone());
611        tracing::info!("Registered list_skills tool");
612
613        Ok(llm_tool)
614    }
615
616    /// Add a custom skill search path.
617    ///
618    /// Skills are discovered from directories containing SKILL.md files.
619    /// By default, `$PWD/.skills/` and `~/.agent-core/skills/` are searched.
620    pub fn add_skill_path(&mut self, path: std::path::PathBuf) -> &mut Self {
621        self.skill_discovery.add_path(path);
622        self
623    }
624
625    /// Load skills from configured directories.
626    ///
627    /// This scans all configured skill paths and registers discovered skills
628    /// in the skill registry. Call this after configuring skill paths.
629    ///
630    /// Returns the number of skills loaded and any errors encountered.
631    pub fn load_skills(&mut self) -> (usize, Vec<SkillDiscoveryError>) {
632        let results = self.skill_discovery.discover();
633        self.register_discovered_skills(results)
634    }
635
636    /// Load skills from specific paths (one-shot, doesn't modify default discovery).
637    ///
638    /// This creates a temporary discovery instance with only the provided paths,
639    /// loads skills from them, and registers them in the skill registry.
640    /// Unlike `add_skill_path()` + `load_skills()`, this doesn't affect the
641    /// default discovery paths used by `reload_skills()`.
642    ///
643    /// Returns the number of skills loaded and any errors encountered.
644    pub fn load_skills_from(&self, paths: Vec<std::path::PathBuf>) -> (usize, Vec<SkillDiscoveryError>) {
645        let mut discovery = SkillDiscovery::empty();
646        for path in paths {
647            discovery.add_path(path);
648        }
649
650        let results = discovery.discover();
651        self.register_discovered_skills(results)
652    }
653
654    /// Helper to register discovered skills and collect errors.
655    ///
656    /// Logs a warning if a skill with the same name already exists (duplicate detection).
657    fn register_discovered_skills(
658        &self,
659        results: Vec<Result<crate::skills::Skill, SkillDiscoveryError>>,
660    ) -> (usize, Vec<SkillDiscoveryError>) {
661        let mut errors = Vec::new();
662        let mut count = 0;
663
664        for result in results {
665            match result {
666                Ok(skill) => {
667                    let skill_name = skill.metadata.name.clone();
668                    let skill_path = skill.path.clone();
669                    let replaced = self.skill_registry.register(skill);
670
671                    if let Some(old_skill) = replaced {
672                        tracing::warn!(
673                            skill_name = %skill_name,
674                            new_path = %skill_path.display(),
675                            old_path = %old_skill.path.display(),
676                            "Duplicate skill name detected - replaced existing skill"
677                        );
678                    }
679
680                    tracing::info!(
681                        skill_name = %skill_name,
682                        skill_path = %skill_path.display(),
683                        "Loaded skill"
684                    );
685                    count += 1;
686                }
687                Err(e) => {
688                    tracing::warn!(
689                        path = %e.path.display(),
690                        error = %e.message,
691                        "Failed to load skill"
692                    );
693                    errors.push(e);
694                }
695            }
696        }
697
698        tracing::info!("Loaded {} skill(s)", count);
699        (count, errors)
700    }
701
702    /// Reload skills from configured directories.
703    ///
704    /// This re-scans all configured skill paths and updates the registry:
705    /// - New skills are added
706    /// - Removed skills are unregistered
707    /// - Existing skills are re-registered (silently updated)
708    ///
709    /// Returns information about what changed (added/removed only).
710    pub fn reload_skills(&mut self) -> SkillReloadResult {
711        let current_names: std::collections::HashSet<String> =
712            self.skill_registry.names().into_iter().collect();
713
714        let results = self.skill_discovery.discover();
715        let mut discovered_names = std::collections::HashSet::new();
716        let mut result = SkillReloadResult::default();
717
718        // Process discovered skills
719        for discovery_result in results {
720            match discovery_result {
721                Ok(skill) => {
722                    let name = skill.metadata.name.clone();
723                    discovered_names.insert(name.clone());
724
725                    if !current_names.contains(&name) {
726                        tracing::info!(skill_name = %name, "Added new skill");
727                        result.added.push(name);
728                    }
729                    self.skill_registry.register(skill);
730                }
731                Err(e) => {
732                    tracing::warn!(
733                        path = %e.path.display(),
734                        error = %e.message,
735                        "Failed to load skill during reload"
736                    );
737                    result.errors.push(e);
738                }
739            }
740        }
741
742        // Find and remove skills that no longer exist
743        for name in &current_names {
744            if !discovered_names.contains(name) {
745                tracing::info!(skill_name = %name, "Removed skill");
746                self.skill_registry.unregister(name);
747                result.removed.push(name.clone());
748            }
749        }
750
751        tracing::info!(
752            added = result.added.len(),
753            removed = result.removed.len(),
754            errors = result.errors.len(),
755            "Skills reloaded"
756        );
757
758        result
759    }
760
761    /// Get skills XML for injection into system prompts.
762    ///
763    /// Returns an XML string listing all available skills that can be
764    /// included in the system prompt to inform the LLM about available capabilities.
765    pub fn skills_prompt_xml(&self) -> String {
766        self.skill_registry.to_prompt_xml()
767    }
768
769    /// Refresh a session's system prompt with current skills.
770    ///
771    /// This updates the session's system prompt to include the current
772    /// `<available_skills>` XML from the skill registry.
773    ///
774    /// Note: This appends the skills XML to the existing system prompt.
775    /// If skills were previously loaded, this may result in duplicate entries.
776    pub async fn refresh_session_skills(&self, session_id: i64) -> Result<(), AgentError> {
777        let skills_xml = self.skills_prompt_xml();
778        if skills_xml.is_empty() {
779            return Ok(());
780        }
781
782        let session = self
783            .controller
784            .get_session(session_id)
785            .await
786            .ok_or_else(|| AgentError::SessionNotFound(session_id))?;
787
788        let current_prompt = session.system_prompt().await.unwrap_or_default();
789
790        // Check if skills are already in the prompt to avoid duplicates
791        let new_prompt = if current_prompt.contains("<available_skills>") {
792            // Replace existing skills section
793            replace_skills_section(&current_prompt, &skills_xml)
794        } else if current_prompt.is_empty() {
795            // No existing prompt, just use skills
796            skills_xml
797        } else {
798            // Append skills section
799            format!("{}\n\n{}", current_prompt, skills_xml)
800        };
801
802        session.set_system_prompt(new_prompt).await;
803        tracing::debug!(session_id, "Refreshed session skills");
804        Ok(())
805    }
806}
807
808/// Replace the <available_skills> section in a system prompt.
809fn replace_skills_section(prompt: &str, new_skills_xml: &str) -> String {
810    if let Some(start) = prompt.find("<available_skills>") {
811        if let Some(end) = prompt.find("</available_skills>") {
812            let end = end + "</available_skills>".len();
813            let mut result = String::with_capacity(prompt.len());
814            result.push_str(&prompt[..start]);
815            result.push_str(new_skills_xml);
816            result.push_str(&prompt[end..]);
817            return result;
818        }
819    }
820    // Fallback: just append
821    format!("{}\n\n{}", prompt, new_skills_xml)
822}
823
824/// Converts a ControllerEvent to a UiMessage for the frontend.
825///
826/// This function maps the internal controller events to UI-friendly messages
827/// that can be displayed in any frontend (TUI, web, etc.).
828///
829/// # Architecture Note
830///
831/// This function serves as the **intentional integration point** between the
832/// controller layer (`ControllerEvent`) and the UI layer (`UiMessage`). It is
833/// defined in the agent module because:
834/// 1. The agent orchestrates both controller and UI components
835/// 2. `UiMessage` is an agent-layer type consumed by frontends
836/// 3. The agent owns the responsibility of bridging these layers
837///
838/// Both `LLMController::send_to_ui()` and `AgentCore` initialization use this
839/// function to translate controller events into UI-displayable messages.
840pub fn convert_controller_event_to_ui_message(event: ControllerEvent) -> UiMessage {
841    match event {
842        ControllerEvent::StreamStart { session_id, .. } => {
843            // Silent - don't display stream start messages
844            UiMessage::System {
845                session_id,
846                message: String::new(),
847            }
848        }
849        ControllerEvent::TextChunk {
850            session_id,
851            text,
852            turn_id,
853        } => UiMessage::TextChunk {
854            session_id,
855            turn_id,
856            text,
857            input_tokens: 0,
858            output_tokens: 0,
859        },
860        ControllerEvent::ToolUseStart {
861            session_id,
862            tool_name,
863            turn_id,
864            ..
865        } => UiMessage::Display {
866            session_id,
867            turn_id,
868            message: format!("Executing tool: {}", tool_name),
869        },
870        ControllerEvent::ToolUse {
871            session_id,
872            tool,
873            display_name,
874            display_title,
875            turn_id,
876        } => UiMessage::ToolExecuting {
877            session_id,
878            turn_id,
879            tool_use_id: tool.id.clone(),
880            display_name: display_name.unwrap_or_else(|| tool.name.clone()),
881            display_title: display_title.unwrap_or_default(),
882        },
883        ControllerEvent::Complete {
884            session_id,
885            turn_id,
886            stop_reason,
887        } => UiMessage::Complete {
888            session_id,
889            turn_id,
890            input_tokens: 0,
891            output_tokens: 0,
892            stop_reason,
893        },
894        ControllerEvent::Error {
895            session_id,
896            error,
897            turn_id,
898        } => UiMessage::Error {
899            session_id,
900            turn_id,
901            error,
902        },
903        ControllerEvent::TokenUpdate {
904            session_id,
905            input_tokens,
906            output_tokens,
907            context_limit,
908        } => UiMessage::TokenUpdate {
909            session_id,
910            turn_id: None,
911            input_tokens,
912            output_tokens,
913            context_limit,
914        },
915        ControllerEvent::ToolResult {
916            session_id,
917            tool_use_id,
918            status,
919            error,
920            turn_id,
921            ..
922        } => UiMessage::ToolCompleted {
923            session_id,
924            turn_id,
925            tool_use_id,
926            status,
927            error,
928        },
929        ControllerEvent::CommandComplete {
930            session_id,
931            command,
932            success,
933            message,
934        } => UiMessage::CommandComplete {
935            session_id,
936            command,
937            success,
938            message,
939        },
940        ControllerEvent::UserInteractionRequired {
941            session_id,
942            tool_use_id,
943            request,
944            turn_id,
945        } => UiMessage::UserInteractionRequired {
946            session_id,
947            tool_use_id,
948            request,
949            turn_id,
950        },
951        ControllerEvent::PermissionRequired {
952            session_id,
953            tool_use_id,
954            request,
955            turn_id,
956        } => UiMessage::PermissionRequired {
957            session_id,
958            tool_use_id,
959            request,
960            turn_id,
961        },
962        ControllerEvent::BatchPermissionRequired {
963            session_id,
964            batch,
965            turn_id,
966        } => UiMessage::BatchPermissionRequired {
967            session_id,
968            batch,
969            turn_id,
970        },
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977    use crate::controller::TurnId;
978
979    #[test]
980    fn test_convert_text_chunk_event() {
981        let event = ControllerEvent::TextChunk {
982            session_id: 1,
983            text: "Hello".to_string(),
984            turn_id: Some(TurnId::new_user_turn(1)),
985        };
986
987        let msg = convert_controller_event_to_ui_message(event);
988
989        match msg {
990            UiMessage::TextChunk {
991                session_id, text, ..
992            } => {
993                assert_eq!(session_id, 1);
994                assert_eq!(text, "Hello");
995            }
996            _ => panic!("Expected TextChunk message"),
997        }
998    }
999
1000    #[test]
1001    fn test_convert_error_event() {
1002        let event = ControllerEvent::Error {
1003            session_id: 1,
1004            error: "Test error".to_string(),
1005            turn_id: None,
1006        };
1007
1008        let msg = convert_controller_event_to_ui_message(event);
1009
1010        match msg {
1011            UiMessage::Error {
1012                session_id, error, ..
1013            } => {
1014                assert_eq!(session_id, 1);
1015                assert_eq!(error, "Test error");
1016            }
1017            _ => panic!("Expected Error message"),
1018        }
1019    }
1020
1021    #[test]
1022    fn test_replace_skills_section_replaces_existing() {
1023        let prompt = "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n</available_skills>\n\nMore text.";
1024        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1025
1026        let result = replace_skills_section(prompt, new_xml);
1027
1028        assert!(result.contains("<skill>new</skill>"));
1029        assert!(!result.contains("<skill>old</skill>"));
1030        assert!(result.contains("System prompt."));
1031        assert!(result.contains("More text."));
1032    }
1033
1034    #[test]
1035    fn test_replace_skills_section_no_existing() {
1036        let prompt = "System prompt without skills.";
1037        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1038
1039        let result = replace_skills_section(prompt, new_xml);
1040
1041        // Falls back to appending
1042        assert!(result.contains("System prompt without skills."));
1043        assert!(result.contains("<skill>new</skill>"));
1044    }
1045
1046    #[test]
1047    fn test_replace_skills_section_malformed_no_closing_tag() {
1048        let prompt = "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n\nNo closing tag.";
1049        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1050
1051        let result = replace_skills_section(prompt, new_xml);
1052
1053        // Falls back to appending since closing tag is missing
1054        assert!(result.contains("<skill>old</skill>"));
1055        assert!(result.contains("<skill>new</skill>"));
1056    }
1057
1058    #[test]
1059    fn test_replace_skills_section_at_end() {
1060        let prompt = "System prompt.\n\n<available_skills>\n  <skill>old</skill>\n</available_skills>";
1061        let new_xml = "<available_skills>\n  <skill>new</skill>\n</available_skills>";
1062
1063        let result = replace_skills_section(prompt, new_xml);
1064
1065        assert!(result.contains("<skill>new</skill>"));
1066        assert!(!result.contains("<skill>old</skill>"));
1067        assert!(result.starts_with("System prompt."));
1068    }
1069}