1use crate::tools::ToolResult;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15#[async_trait]
19pub trait Plugin: Send + Sync {
20 fn name(&self) -> &str;
22
23 fn version(&self) -> &str;
25
26 fn methods(&self) -> Vec<MethodSpec>;
28
29 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError>;
31
32 async fn on_register(&self, _ctx: &mut PluginContext) {}
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct MethodSpec {
40 pub name: String,
41 pub description: String,
42 pub params: HashMap<String, String>,
43 pub returns: String,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
48pub struct PluginError {
49 pub code: i32,
50 pub message: String,
51 pub data: Option<Value>,
52}
53
54impl PluginError {
55 pub fn new(code: i32, message: &str) -> Self {
56 Self {
57 code,
58 message: message.to_string(),
59 data: None,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
68pub enum LifecycleEvent {
69 BeforeToolCall(String, String),
71 AfterToolCall(String, String, ToolResult),
73 SessionStart(String),
75 SessionEnd(String),
77 AgentStart(String, String),
79 AgentDone(String, String),
81}
82
83pub use crate::agent::hooks::HookDecision;
85
86#[async_trait]
88pub trait LifecycleHook: Send + Sync {
89 fn name(&self) -> &str;
90
91 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()>;
93}
94
95#[async_trait]
97pub trait PreToolHook: Send + Sync {
98 fn name(&self) -> &str;
99 async fn before_tool_call(&self, name: &str, arguments: &str) -> HookDecision;
100}
101
102#[async_trait]
104pub trait PostToolHook: Send + Sync {
105 fn name(&self) -> &str;
106 async fn after_tool_call(&self, name: &str, arguments: &str, result: &ToolResult);
107}
108
109#[derive(Default)]
111pub struct HookManager {
112 lifecycle_hooks: Vec<Arc<dyn LifecycleHook>>,
113 pre_hooks: Vec<Arc<dyn PreToolHook>>,
114 post_hooks: Vec<Arc<dyn PostToolHook>>,
115}
116
117impl HookManager {
118 pub fn new() -> Self {
119 Self::default()
120 }
121
122 pub fn register_lifecycle(&mut self, hook: Arc<dyn LifecycleHook>) {
123 self.lifecycle_hooks.push(hook);
124 }
125
126 pub fn register_pre_tool(&mut self, hook: Arc<dyn PreToolHook>) {
127 self.pre_hooks.push(hook);
128 }
129
130 pub fn register_post_tool(&mut self, hook: Arc<dyn PostToolHook>) {
131 self.post_hooks.push(hook);
132 }
133
134 pub async fn emit(&self, event: &LifecycleEvent) {
136 for hook in &self.lifecycle_hooks {
137 if let Err(e) = hook.on_event(event).await {
138 tracing::warn!(
139 "LifecycleHook '{}' error on {:?}: {}",
140 hook.name(),
141 event,
142 e
143 );
144 }
145 }
146 }
147
148 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
150 for hook in &self.pre_hooks {
151 match hook.before_tool_call(name, arguments).await {
152 HookDecision::Block(reason) => return HookDecision::Block(reason),
153 HookDecision::Allow => {}
154 }
155 }
156 HookDecision::Allow
157 }
158
159 pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
161 for hook in &self.post_hooks {
162 hook.after_tool_call(name, arguments, result).await;
163 }
164 }
165}
166
167#[derive(Default)]
169pub struct PluginContext {
170 pub tools: Vec<Arc<dyn crate::tools::Tool>>,
172 pub hooks: HookManager,
174 pub channels: Vec<(String, crate::channels::ChannelBuilder)>,
176}
177
178impl PluginContext {
179 pub fn new() -> Self {
180 Self::default()
181 }
182
183 pub fn register_tool(&mut self, tool: Arc<dyn crate::tools::Tool>) {
185 self.tools.push(tool);
186 }
187
188 pub fn register_channel<F>(&mut self, name: impl Into<String>, builder: F)
194 where
195 F: Fn(
196 &crate::channels::ChannelSettings,
197 ) -> anyhow::Result<Box<dyn crate::channels::Channel>>
198 + Send
199 + Sync
200 + 'static,
201 {
202 self.channels.push((name.into(), Arc::new(builder)));
203 }
204
205 pub fn register_lifecycle_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
207 self.hooks.register_lifecycle(hook);
208 }
209
210 pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
212 self.hooks.register_pre_tool(hook);
213 }
214
215 pub fn register_post_tool_hook(&mut self, hook: Arc<dyn PostToolHook>) {
217 self.hooks.register_post_tool(hook);
218 }
219}
220
221pub struct PluginRegistry {
225 plugins: HashMap<String, Arc<dyn Plugin>>,
226 hooks: HookManager,
227 channels: crate::channels::ChannelRegistry,
228 tools: Vec<Arc<dyn crate::tools::Tool>>,
229 host_plugins: Vec<crate::plugin_hosts::HostPluginEntry>,
230}
231
232impl PluginRegistry {
233 pub fn new() -> Self {
234 Self {
235 plugins: HashMap::new(),
236 hooks: HookManager::new(),
237 channels: crate::channels::ChannelRegistry::with_builtins(),
238 tools: Vec::new(),
239 host_plugins: Vec::new(),
240 }
241 }
242
243 pub fn tools(&self) -> &[Arc<dyn crate::tools::Tool>] {
249 &self.tools
250 }
251
252 pub fn host_plugins(&self) -> &[crate::plugin_hosts::HostPluginEntry] {
254 &self.host_plugins
255 }
256
257 pub fn channels(&self) -> &crate::channels::ChannelRegistry {
260 &self.channels
261 }
262
263 pub fn ingest_host_plugins(&mut self, workspace: &std::path::Path, extra: &[PathBuf]) {
271 self.ingest_host_plugins_trusting(workspace, extra, &[]);
272 }
273
274 pub fn ingest_host_plugins_trusting(
281 &mut self,
282 workspace: &std::path::Path,
283 extra: &[PathBuf],
284 trusted: &[String],
285 ) {
286 let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
287 for p in &found {
288 tracing::info!(
289 "[plugin-host] {:?} {} {:?}",
290 p.kind,
291 p.name.as_deref().unwrap_or("?"),
292 p.path
293 );
294 for tool in crate::plugin_exec::HostPluginToolAdapter::build(p, trusted) {
295 let tool: Arc<dyn crate::tools::Tool> = Arc::new(tool);
296 tracing::info!(
297 "[plugin-host] {} contributed trusted tool: {}",
298 p.id,
299 tool.name()
300 );
301 self.tools.push(tool);
302 }
303 }
304 self.host_plugins.extend(found);
305 }
306
307 pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
309 let name = plugin.name().to_string();
310 let mut ctx = PluginContext::default();
311 plugin.on_register(&mut ctx).await;
312 for tool in ctx.tools {
314 tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
315 self.tools.push(tool);
316 }
317 for (channel_name, builder) in ctx.channels {
319 tracing::info!("[plugin] '{}' registered channel: {}", name, channel_name);
320 self.channels.register_builder(channel_name, builder);
321 }
322 self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
324 self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
325 self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
326 self.plugins.insert(name, plugin);
327 }
328
329 pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
331 self.plugins.insert(plugin.name().to_string(), plugin);
332 }
333
334 pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
336 self.hooks.register_lifecycle(hook);
337 }
338
339 pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
341 self.hooks.register_pre_tool(hook);
342 }
343
344 pub fn hooks(&self) -> &HookManager {
346 &self.hooks
347 }
348
349 pub async fn call(
351 &self,
352 plugin: &str,
353 method: &str,
354 params: Value,
355 ) -> Result<Value, PluginError> {
356 let p = self
357 .plugins
358 .get(plugin)
359 .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
360 p.call(method, params).await
361 }
362
363 pub fn list(&self) -> Vec<String> {
365 self.plugins.keys().cloned().collect()
366 }
367
368 pub fn info(&self, name: &str) -> Option<PluginInfo> {
370 self.plugins.get(name).map(|p| PluginInfo {
371 name: p.name().to_string(),
372 version: p.version().to_string(),
373 methods: p.methods(),
374 })
375 }
376
377 pub async fn emit(&self, event: &LifecycleEvent) {
379 self.hooks.emit(event).await;
380 }
381
382 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
384 self.hooks.check_pre_tool(name, arguments).await
385 }
386
387 pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
389 self.hooks.notify_post_tool(name, arguments, result).await;
390 }
391}
392
393impl Default for PluginRegistry {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399pub struct ShellPlugin {
403 policy: crate::policy::ExecutionPolicy,
404}
405
406impl ShellPlugin {
407 pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
408 Self { policy }
409 }
410}
411
412#[async_trait]
413impl Plugin for ShellPlugin {
414 fn name(&self) -> &str {
415 "tools"
416 }
417
418 fn version(&self) -> &str {
419 "0.1.0"
420 }
421
422 fn methods(&self) -> Vec<MethodSpec> {
423 vec![MethodSpec {
424 name: "shell".to_string(),
425 description: "Execute shell command".to_string(),
426 params: {
427 let mut m = HashMap::new();
428 m.insert("cmd".to_string(), "string".to_string());
429 m
430 },
431 returns: "string".to_string(),
432 }]
433 }
434
435 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
436 if method != "shell" {
437 return Err(PluginError::new(-32601, "Method not found"));
438 }
439 if !self.policy.allow_plugin_shell {
440 return Err(PluginError::new(
441 -32604,
442 "Plugin shell execution is disabled by policy",
443 ));
444 }
445
446 let cmd = params
447 .get("cmd")
448 .and_then(|v| v.as_str())
449 .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
450
451 if let Err(reason) = self.policy.check_shell_command(cmd) {
452 return Err(PluginError::new(-32604, &reason));
453 }
454
455 match crate::process_cmd::run_argv_command(cmd, 120).await {
456 Ok((output, ok)) => Ok(serde_json::json!({
457 "stdout": output,
458 "success": ok,
459 })),
460 Err(e) => Err(PluginError::new(-32603, &e.to_string())),
461 }
462 }
463}
464
465#[derive(Debug, Serialize, Deserialize)]
468pub struct PluginInfo {
469 pub name: String,
470 pub version: String,
471 pub methods: Vec<MethodSpec>,
472}
473
474pub struct SessionNoteLifecycleHook {
478 workspace: std::path::PathBuf,
479}
480
481impl SessionNoteLifecycleHook {
482 pub fn new(workspace: std::path::PathBuf) -> Self {
483 Self { workspace }
484 }
485}
486
487#[async_trait]
488impl LifecycleHook for SessionNoteLifecycleHook {
489 fn name(&self) -> &str {
490 "session_note"
491 }
492
493 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
494 if let LifecycleEvent::AgentDone(chat_id, response) = event {
495 let redacted = crate::redaction::redact_text(response);
496 let preview: String = redacted.chars().take(200).collect();
497 if !preview.is_empty() {
498 let _ = crate::memory::session_note::append_session_note(
499 &self.workspace,
500 chat_id,
501 &preview,
502 );
503 }
504 }
505 Ok(())
506 }
507}
508
509pub struct LoggingLifecycleHook;
511
512#[async_trait]
513impl LifecycleHook for LoggingLifecycleHook {
514 fn name(&self) -> &str {
515 "logging"
516 }
517
518 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
519 match event {
520 LifecycleEvent::BeforeToolCall(name, args) => {
521 tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
522 }
523 LifecycleEvent::AfterToolCall(name, _args, result) => {
524 tracing::debug!(
525 "[hook] after_tool {} is_error:{} len:{}",
526 name,
527 result.is_error,
528 result.output.len()
529 );
530 }
531 LifecycleEvent::SessionStart(id) => {
532 tracing::info!("[hook] session_start {}", id);
533 }
534 LifecycleEvent::SessionEnd(id) => {
535 tracing::info!("[hook] session_end {}", id);
536 }
537 LifecycleEvent::AgentStart(chat_id, msg) => {
538 tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
539 }
540 LifecycleEvent::AgentDone(chat_id, response) => {
541 tracing::debug!(
542 "[hook] agent_done {} response_len:{}",
543 chat_id,
544 response.len()
545 );
546 }
547 }
548 Ok(())
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use serde_json::json;
556
557 struct TestPlugin;
558
559 #[async_trait]
560 impl Plugin for TestPlugin {
561 fn name(&self) -> &str {
562 "test"
563 }
564
565 fn version(&self) -> &str {
566 "0.1.0"
567 }
568
569 fn methods(&self) -> Vec<MethodSpec> {
570 vec![MethodSpec {
571 name: "echo".to_string(),
572 description: "Echo input".to_string(),
573 params: HashMap::new(),
574 returns: "object".to_string(),
575 }]
576 }
577
578 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
579 match method {
580 "echo" => Ok(json!({ "result": params })),
581 _ => Err(PluginError::new(-32601, "Method not found")),
582 }
583 }
584 }
585
586 #[tokio::test]
587 async fn shell_plugin_uses_argv_not_shell_when_allowed() {
588 let policy = crate::policy::ExecutionPolicy {
589 allow_plugin_shell: true,
590 ..Default::default()
591 };
592 let plugin = ShellPlugin::new(policy);
593 let result = plugin
594 .call("shell", json!({ "cmd": "echo plugin_ok" }))
595 .await
596 .unwrap();
597 assert_eq!(result["success"], true);
598 assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
599 }
600
601 #[tokio::test]
602 async fn shell_plugin_denied_when_policy_off() {
603 let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
604 allow_plugin_shell: false,
605 ..Default::default()
606 });
607 let err = plugin
608 .call("shell", json!({ "cmd": "echo x" }))
609 .await
610 .unwrap_err();
611 assert_eq!(err.code, -32604);
612 }
613
614 #[tokio::test]
615 async fn test_plugin_call() {
616 let mut registry = PluginRegistry::new();
617 registry.register_sync(Arc::new(TestPlugin));
618
619 let result = registry
620 .call("test", "echo", json!({ "code": "fn main() {}" }))
621 .await
622 .unwrap();
623
624 assert!(result.get("result").is_some());
625 }
626
627 #[tokio::test]
628 async fn test_hook_manager_emit() {
629 let mut manager = HookManager::new();
630 manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
631 let _ = manager.check_pre_tool("test", "{}").await;
632 manager
633 .notify_post_tool("test", "{}", &ToolResult::success("ok"))
634 .await;
635 }
637
638 #[tokio::test]
639 async fn test_plugin_context_register_tool() {
640 let mut ctx = PluginContext::new();
641 ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
643 assert!(ctx.hooks.lifecycle_hooks.len() == 1);
644 }
645}