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 let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
272 for p in &found {
273 tracing::info!(
274 "[plugin-host] {:?} {} {:?}",
275 p.kind,
276 p.name.as_deref().unwrap_or("?"),
277 p.path
278 );
279 }
280 self.host_plugins.extend(found);
281 }
282
283 pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
285 let name = plugin.name().to_string();
286 let mut ctx = PluginContext::default();
287 plugin.on_register(&mut ctx).await;
288 for tool in ctx.tools {
290 tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
291 self.tools.push(tool);
292 }
293 for (channel_name, builder) in ctx.channels {
295 tracing::info!("[plugin] '{}' registered channel: {}", name, channel_name);
296 self.channels.register_builder(channel_name, builder);
297 }
298 self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
300 self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
301 self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
302 self.plugins.insert(name, plugin);
303 }
304
305 pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
307 self.plugins.insert(plugin.name().to_string(), plugin);
308 }
309
310 pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
312 self.hooks.register_lifecycle(hook);
313 }
314
315 pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
317 self.hooks.register_pre_tool(hook);
318 }
319
320 pub fn hooks(&self) -> &HookManager {
322 &self.hooks
323 }
324
325 pub async fn call(
327 &self,
328 plugin: &str,
329 method: &str,
330 params: Value,
331 ) -> Result<Value, PluginError> {
332 let p = self
333 .plugins
334 .get(plugin)
335 .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
336 p.call(method, params).await
337 }
338
339 pub fn list(&self) -> Vec<String> {
341 self.plugins.keys().cloned().collect()
342 }
343
344 pub fn info(&self, name: &str) -> Option<PluginInfo> {
346 self.plugins.get(name).map(|p| PluginInfo {
347 name: p.name().to_string(),
348 version: p.version().to_string(),
349 methods: p.methods(),
350 })
351 }
352
353 pub async fn emit(&self, event: &LifecycleEvent) {
355 self.hooks.emit(event).await;
356 }
357
358 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
360 self.hooks.check_pre_tool(name, arguments).await
361 }
362
363 pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
365 self.hooks.notify_post_tool(name, arguments, result).await;
366 }
367}
368
369impl Default for PluginRegistry {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375pub struct ShellPlugin {
379 policy: crate::policy::ExecutionPolicy,
380}
381
382impl ShellPlugin {
383 pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
384 Self { policy }
385 }
386}
387
388#[async_trait]
389impl Plugin for ShellPlugin {
390 fn name(&self) -> &str {
391 "tools"
392 }
393
394 fn version(&self) -> &str {
395 "0.1.0"
396 }
397
398 fn methods(&self) -> Vec<MethodSpec> {
399 vec![MethodSpec {
400 name: "shell".to_string(),
401 description: "Execute shell command".to_string(),
402 params: {
403 let mut m = HashMap::new();
404 m.insert("cmd".to_string(), "string".to_string());
405 m
406 },
407 returns: "string".to_string(),
408 }]
409 }
410
411 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
412 if method != "shell" {
413 return Err(PluginError::new(-32601, "Method not found"));
414 }
415 if !self.policy.allow_plugin_shell {
416 return Err(PluginError::new(
417 -32604,
418 "Plugin shell execution is disabled by policy",
419 ));
420 }
421
422 let cmd = params
423 .get("cmd")
424 .and_then(|v| v.as_str())
425 .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
426
427 if let Err(reason) = self.policy.check_shell_command(cmd) {
428 return Err(PluginError::new(-32604, &reason));
429 }
430
431 match crate::process_cmd::run_argv_command(cmd, 120).await {
432 Ok((output, ok)) => Ok(serde_json::json!({
433 "stdout": output,
434 "success": ok,
435 })),
436 Err(e) => Err(PluginError::new(-32603, &e.to_string())),
437 }
438 }
439}
440
441#[derive(Debug, Serialize, Deserialize)]
444pub struct PluginInfo {
445 pub name: String,
446 pub version: String,
447 pub methods: Vec<MethodSpec>,
448}
449
450pub struct SessionNoteLifecycleHook {
454 workspace: std::path::PathBuf,
455}
456
457impl SessionNoteLifecycleHook {
458 pub fn new(workspace: std::path::PathBuf) -> Self {
459 Self { workspace }
460 }
461}
462
463#[async_trait]
464impl LifecycleHook for SessionNoteLifecycleHook {
465 fn name(&self) -> &str {
466 "session_note"
467 }
468
469 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
470 if let LifecycleEvent::AgentDone(chat_id, response) = event {
471 let redacted = crate::redaction::redact_text(response);
472 let preview: String = redacted.chars().take(200).collect();
473 if !preview.is_empty() {
474 let _ = crate::memory::session_note::append_session_note(
475 &self.workspace,
476 chat_id,
477 &preview,
478 );
479 }
480 }
481 Ok(())
482 }
483}
484
485pub struct LoggingLifecycleHook;
487
488#[async_trait]
489impl LifecycleHook for LoggingLifecycleHook {
490 fn name(&self) -> &str {
491 "logging"
492 }
493
494 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
495 match event {
496 LifecycleEvent::BeforeToolCall(name, args) => {
497 tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
498 }
499 LifecycleEvent::AfterToolCall(name, _args, result) => {
500 tracing::debug!(
501 "[hook] after_tool {} is_error:{} len:{}",
502 name,
503 result.is_error,
504 result.output.len()
505 );
506 }
507 LifecycleEvent::SessionStart(id) => {
508 tracing::info!("[hook] session_start {}", id);
509 }
510 LifecycleEvent::SessionEnd(id) => {
511 tracing::info!("[hook] session_end {}", id);
512 }
513 LifecycleEvent::AgentStart(chat_id, msg) => {
514 tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
515 }
516 LifecycleEvent::AgentDone(chat_id, response) => {
517 tracing::debug!(
518 "[hook] agent_done {} response_len:{}",
519 chat_id,
520 response.len()
521 );
522 }
523 }
524 Ok(())
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use serde_json::json;
532
533 struct TestPlugin;
534
535 #[async_trait]
536 impl Plugin for TestPlugin {
537 fn name(&self) -> &str {
538 "test"
539 }
540
541 fn version(&self) -> &str {
542 "0.1.0"
543 }
544
545 fn methods(&self) -> Vec<MethodSpec> {
546 vec![MethodSpec {
547 name: "echo".to_string(),
548 description: "Echo input".to_string(),
549 params: HashMap::new(),
550 returns: "object".to_string(),
551 }]
552 }
553
554 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
555 match method {
556 "echo" => Ok(json!({ "result": params })),
557 _ => Err(PluginError::new(-32601, "Method not found")),
558 }
559 }
560 }
561
562 #[tokio::test]
563 async fn shell_plugin_uses_argv_not_shell_when_allowed() {
564 let policy = crate::policy::ExecutionPolicy {
565 allow_plugin_shell: true,
566 ..Default::default()
567 };
568 let plugin = ShellPlugin::new(policy);
569 let result = plugin
570 .call("shell", json!({ "cmd": "echo plugin_ok" }))
571 .await
572 .unwrap();
573 assert_eq!(result["success"], true);
574 assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
575 }
576
577 #[tokio::test]
578 async fn shell_plugin_denied_when_policy_off() {
579 let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
580 allow_plugin_shell: false,
581 ..Default::default()
582 });
583 let err = plugin
584 .call("shell", json!({ "cmd": "echo x" }))
585 .await
586 .unwrap_err();
587 assert_eq!(err.code, -32604);
588 }
589
590 #[tokio::test]
591 async fn test_plugin_call() {
592 let mut registry = PluginRegistry::new();
593 registry.register_sync(Arc::new(TestPlugin));
594
595 let result = registry
596 .call("test", "echo", json!({ "code": "fn main() {}" }))
597 .await
598 .unwrap();
599
600 assert!(result.get("result").is_some());
601 }
602
603 #[tokio::test]
604 async fn test_hook_manager_emit() {
605 let mut manager = HookManager::new();
606 manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
607 let _ = manager.check_pre_tool("test", "{}").await;
608 manager
609 .notify_post_tool("test", "{}", &ToolResult::success("ok"))
610 .await;
611 }
613
614 #[tokio::test]
615 async fn test_plugin_context_register_tool() {
616 let mut ctx = PluginContext::new();
617 ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
619 assert!(ctx.hooks.lifecycle_hooks.len() == 1);
620 }
621}