telos_agent/integrations/plugin/registry/
apply.rs1use 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 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 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 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 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 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 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 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 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 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 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
348fn 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}