Skip to main content

telos_agent/integrations/plugin/registry/
apply.rs

1//! Apply enabled plugin components to agent registries.
2
3use std::collections::HashMap;
4
5use crate::agent::policies::{PolicyEntry, PolicyPoint};
6use crate::integrations::mcp::McpServerConfig;
7use crate::integrations::plugin::PluginError;
8use crate::integrations::plugin::manifest::{McpServerEntry, McpServersConfig};
9use crate::integrations::plugin::policy_loader::CommandPolicy;
10use crate::integrations::plugin::registry::lifecycle::PluginRegistry;
11use crate::orchestration::subagent::{AgentDefinition, AgentSource, SubagentRegistry};
12use std::path::Path;
13
14impl PluginRegistry {
15    /// Apply all enabled plugins' components into the agent extension registries.
16    ///
17    /// # Namespacing
18    /// Plugin tools are registered as `plugin__<plugin_name>__<tool_name>` to
19    /// avoid conflicts with built-in tools.
20    ///
21    /// # Errors
22    /// Returns a list of per-plugin errors. Plugins that fail component loading
23    /// are marked Degraded; their successfully-loaded components remain active.
24    pub fn apply(
25        &self,
26        tools: &mut crate::tools::api::ToolRegistry,
27        policies: &mut crate::agent::policies::PolicyRegistry,
28        command_env: &HashMap<String, String>,
29        skills: &mut crate::knowledge::skills::SkillRegistry,
30        mcp: &mut crate::integrations::mcp::McpManager,
31        prompt: &mut crate::agent::prompt::PromptAssembly,
32    ) -> Result<(), Vec<PluginError>> {
33        let enabled = self.list_enabled();
34        let mut errors = Vec::new();
35
36        for entry in enabled {
37            let plugin = &entry.plugin;
38            let plugin_id_str = plugin.id.name.clone();
39            let mut component_count = 0;
40            let mut loaded_count = 0;
41
42            // --- Tools ---
43            for tool_path in &plugin.resolved_tools {
44                component_count += 1;
45                match crate::integrations::plugin::tool_loader::load_tool_spec(tool_path) {
46                    Ok(mut spec) => {
47                        spec.name = format!("plugin__{plugin_id_str}__{}", spec.name);
48                        let cmd_tool =
49                            crate::integrations::plugin::tool_loader::CommandTool::from_spec(
50                                spec,
51                                &plugin.path,
52                            );
53                        tools.register(cmd_tool);
54                        loaded_count += 1;
55                    }
56                    Err(e) => {
57                        tracing::warn!(
58                            plugin = %plugin.id,
59                            tool = %tool_path.display(),
60                            error = %e,
61                            "failed to load plugin tool"
62                        );
63                    }
64                }
65            }
66
67            // --- Policies ---
68            if let Some(ref config) = plugin.manifest.policies {
69                component_count += 1;
70                let policy_count = register_plugin_policies(
71                    policies,
72                    config,
73                    &plugin_id_str,
74                    &plugin.path,
75                    command_env,
76                );
77                if policy_count > 0 {
78                    loaded_count += 1;
79                }
80            }
81
82            // --- MCP Servers ---
83            if let Some(ref mcp_servers) = plugin.manifest.mcp_servers {
84                component_count += 1;
85                if let Err(e) =
86                    register_plugin_mcp_servers(mcp, mcp_servers, &plugin.path, &plugin_id_str)
87                {
88                    tracing::warn!(
89                        plugin = %plugin.id,
90                        error = %e,
91                        "failed to register plugin MCP servers"
92                    );
93                } else {
94                    loaded_count += 1;
95                }
96            }
97
98            // --- LSP Servers ---
99            if plugin.manifest.lsp_servers.is_some() {
100                component_count += 1;
101                tracing::info!(
102                    plugin = %plugin.id,
103                    "plugin declares LSP servers — LSP integration not yet wired into the agent runtime"
104                );
105                loaded_count += 1;
106            }
107
108            // --- Output Styles ---
109            if let Some(ref styles) = plugin.manifest.output_styles {
110                component_count += styles.len();
111                for style_path in styles {
112                    let abs_path = plugin.path.join(style_path);
113                    if abs_path.exists() {
114                        tracing::info!(
115                            plugin = %plugin.id,
116                            style = %style_path,
117                            "plugin output style available (not yet wired into the agent runtime)"
118                        );
119                        loaded_count += 1;
120                    } else {
121                        tracing::warn!(
122                            plugin = %plugin.id,
123                            style = %style_path,
124                            "plugin output style file not found"
125                        );
126                    }
127                }
128            }
129
130            // --- Skills ---
131            // Resolve skill paths: each entry can be a .md file or a directory.
132            for skill_path in &plugin.resolved_skills {
133                component_count += 1;
134                let source =
135                    crate::knowledge::skills::SkillSource::Plugin { plugin_id: plugin.id.clone() };
136                if skill_path.is_dir() {
137                    match skills.inject_skills_from_dir(skill_path, source) {
138                        Ok(()) => loaded_count += 1,
139                        Err(e) => {
140                            tracing::warn!(
141                                plugin = %plugin.id,
142                                path = %skill_path.display(),
143                                error = %e,
144                                "failed to load plugin skills from directory"
145                            );
146                        }
147                    }
148                } else if skill_path.is_file() && skill_path.extension().is_some_and(|e| e == "md")
149                {
150                    if let Some(skill) =
151                        crate::knowledge::skills::SkillLoader::load_skill_file(skill_path, source)
152                    {
153                        skills.register(skill);
154                        loaded_count += 1;
155                    } else {
156                        tracing::warn!(
157                            plugin = %plugin.id,
158                            path = %skill_path.display(),
159                            "failed to parse plugin skill file"
160                        );
161                    }
162                }
163            }
164
165            // --- Prompt sections ---
166            for section_path in &plugin.resolved_prompt_sections {
167                component_count += 1;
168                if section_path.is_file() {
169                    match std::fs::read_to_string(section_path) {
170                        Ok(template) => {
171                            let template =
172                                template.replace("${PLUGIN_ROOT}", &plugin.path.to_string_lossy());
173                            let stem = section_path
174                                .file_stem()
175                                .and_then(|s| s.to_str())
176                                .unwrap_or("unknown");
177                            let section = crate::integrations::plugin::PluginPromptSection {
178                                name: format!("plugin_{plugin_id_str}_{stem}"),
179                                template,
180                            };
181                            prompt.add(section);
182                            loaded_count += 1;
183                        }
184                        Err(e) => {
185                            tracing::warn!(
186                                plugin = %plugin.id,
187                                section = %section_path.display(),
188                                error = %e,
189                                "failed to read plugin prompt section"
190                            );
191                        }
192                    }
193                }
194            }
195
196            if component_count > 0 && loaded_count < component_count {
197                errors.push(PluginError::Degraded {
198                    id: plugin.id.clone(),
199                    loaded: loaded_count,
200                    total: component_count,
201                });
202            }
203        }
204
205        if errors.is_empty() { Ok(()) } else { Err(errors) }
206    }
207
208    /// Re-apply only prompt sections from enabled plugins into a prompt assembly.
209    ///
210    /// This is a lighter variant of [`apply`](Self::apply) — it does not
211    /// re-register tools, policies, skills, or MCP servers. Useful when the
212    /// prompt assembly is rebuilt (e.g. after tools change).
213    pub fn apply_prompt_sections(&self, prompt: &mut crate::agent::prompt::PromptAssembly) {
214        for entry in self.list_enabled() {
215            let plugin = &entry.plugin;
216            let plugin_id_str = plugin.id.name.clone();
217            for section_path in &plugin.resolved_prompt_sections {
218                if section_path.is_file()
219                    && let Ok(template) = std::fs::read_to_string(section_path)
220                {
221                    let template =
222                        template.replace("${PLUGIN_ROOT}", &plugin.path.to_string_lossy());
223                    let stem =
224                        section_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
225                    let section = crate::integrations::plugin::PluginPromptSection {
226                        name: format!("plugin_{plugin_id_str}_{stem}"),
227                        template,
228                    };
229                    prompt.add(section);
230                }
231            }
232        }
233    }
234
235    /// Apply enabled plugin agent definitions into a subagent registry.
236    ///
237    /// Plugin agents are registered as `<plugin_name>:<agent_name>` so they do
238    /// not collide with built-in, project, or user agent names.
239    pub fn apply_subagents(
240        &self,
241        subagents: &mut SubagentRegistry,
242    ) -> Result<(), Vec<PluginError>> {
243        let mut errors = Vec::new();
244
245        for entry in self.list_enabled() {
246            let plugin = &entry.plugin;
247            let mut component_count = 0;
248            let mut loaded_count = 0;
249
250            for agent_path in &plugin.resolved_agents {
251                if agent_path.is_dir() {
252                    let paths = match markdown_files(agent_path) {
253                        Ok(paths) => paths,
254                        Err(err) => {
255                            errors.push(PluginError::ComponentLoadFailed(
256                                plugin.id.clone(),
257                                format!("failed to read agent dir {}: {err}", agent_path.display()),
258                            ));
259                            continue;
260                        }
261                    };
262                    component_count += paths.len();
263                    for path in paths {
264                        match load_plugin_agent(&path, &plugin.id.name) {
265                            Ok(agent) => {
266                                subagents.register(agent);
267                                loaded_count += 1;
268                            }
269                            Err(err) => {
270                                errors.push(PluginError::ComponentLoadFailed(
271                                    plugin.id.clone(),
272                                    format!("failed to load agent {}: {err}", path.display()),
273                                ));
274                            }
275                        }
276                    }
277                } else {
278                    component_count += 1;
279                    match load_plugin_agent(agent_path, &plugin.id.name) {
280                        Ok(agent) => {
281                            subagents.register(agent);
282                            loaded_count += 1;
283                        }
284                        Err(err) => {
285                            errors.push(PluginError::ComponentLoadFailed(
286                                plugin.id.clone(),
287                                format!("failed to load agent {}: {err}", agent_path.display()),
288                            ));
289                        }
290                    }
291                }
292            }
293
294            if component_count > 0 && loaded_count < component_count {
295                errors.push(PluginError::Degraded {
296                    id: plugin.id.clone(),
297                    loaded: loaded_count,
298                    total: component_count,
299                });
300            }
301        }
302
303        if errors.is_empty() { Ok(()) } else { Err(errors) }
304    }
305}
306
307fn register_plugin_policies(
308    registry: &mut crate::agent::policies::PolicyRegistry,
309    config: &crate::integrations::plugin::manifest::PoliciesConfig,
310    plugin: &str,
311    plugin_root: &Path,
312    command_env: &HashMap<String, String>,
313) -> usize {
314    let mut count = 0;
315    let mut add = |point, command: &crate::integrations::plugin::manifest::CommandPolicyDef| {
316        let name = format!("plugin_{plugin}_policy_{count}");
317        registry.register(PolicyEntry {
318            point,
319            policy: std::sync::Arc::new(CommandPolicy::new(
320                name,
321                command.command.clone(),
322                command.args.clone(),
323                command.timeout,
324                plugin_root.to_path_buf(),
325                command_env.clone(),
326            )),
327        });
328        count += 1;
329    };
330    for item in &config.session_start {
331        add(PolicyPoint::SessionStart { mode: item.mode }, &item.command);
332    }
333    for item in &config.model_response {
334        add(PolicyPoint::ModelResponse, item);
335    }
336    for item in &config.tool_before_invoke {
337        add(PolicyPoint::ToolBeforeInvoke { matcher: item.matcher.clone() }, &item.command);
338    }
339    for item in &config.tool_after_invoke {
340        add(PolicyPoint::ToolAfterInvoke { matcher: item.matcher.clone() }, &item.command);
341    }
342    for item in &config.turn_before_finish {
343        add(PolicyPoint::TurnBeforeFinish, item);
344    }
345    count
346}
347
348/// Register MCP servers declared by a plugin into the MCP manager.
349fn register_plugin_mcp_servers(
350    mcp: &crate::integrations::mcp::McpManager,
351    mcp_servers: &McpServersConfig,
352    plugin_path: &Path,
353    plugin_id_str: &str,
354) -> Result<(), PluginError> {
355    let servers = match mcp_servers {
356        McpServersConfig::Inline(map) => map.clone(),
357        McpServersConfig::File(rel_path) => {
358            let abs_path = plugin_path.join(rel_path);
359            let content = std::fs::read_to_string(&abs_path).map_err(|e| {
360                PluginError::Io(format!(
361                    "failed to read plugin MCP config {}: {e}",
362                    abs_path.display()
363                ))
364            })?;
365            let value: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
366                PluginError::Json(format!(
367                    "failed to parse plugin MCP config {}: {e}",
368                    abs_path.display()
369                ))
370            })?;
371            let config_val = value.get("mcpServers").unwrap_or(&value);
372            serde_json::from_value(config_val.clone()).map_err(|e| {
373                PluginError::Json(format!("failed to decode plugin MCP servers: {e}"))
374            })?
375        }
376    };
377    let namespace_id = |name: &str| -> String { format!("plugin__{plugin_id_str}__{name}") };
378    let server_configs: HashMap<String, McpServerConfig> = servers
379        .into_iter()
380        .map(|(name, entry): (String, McpServerEntry)| {
381            (namespace_id(&name), mcp_server_entry_to_config(entry))
382        })
383        .collect();
384    tokio::task::block_in_place(move || {
385        tokio::runtime::Handle::current().block_on(async {
386            mcp.register_servers(server_configs).await;
387        })
388    });
389    Ok(())
390}
391
392fn mcp_server_entry_to_config(entry: McpServerEntry) -> McpServerConfig {
393    McpServerConfig {
394        command: entry.command,
395        args: entry.args,
396        env: entry.env,
397        cwd: None,
398        auto_connect: entry.auto_connect,
399        timeout_ms: entry.timeout_ms,
400    }
401}
402
403fn load_plugin_agent(path: &Path, plugin_name: &str) -> Result<AgentDefinition, crate::AgentError> {
404    let content = std::fs::read_to_string(path).map_err(|err| {
405        crate::AgentError::Config(format!("failed to read agent file {}: {err}", path.display()))
406    })?;
407    let mut agent = AgentDefinition::from_markdown(
408        &content,
409        AgentSource::Plugin { plugin: plugin_name.to_string(), path: path.display().to_string() },
410    )?;
411    agent.name = format!("{plugin_name}:{}", agent.name);
412    Ok(agent)
413}
414
415fn markdown_files(dir: &Path) -> Result<Vec<std::path::PathBuf>, std::io::Error> {
416    let mut paths = Vec::new();
417    for entry in std::fs::read_dir(dir)? {
418        let path = entry?.path();
419        if path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext == "md") {
420            paths.push(path);
421        }
422    }
423    paths.sort();
424    Ok(paths)
425}