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}
175
176impl PluginContext {
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 pub fn register_tool(&mut self, tool: Arc<dyn crate::tools::Tool>) {
183 self.tools.push(tool);
184 }
185
186 pub fn register_lifecycle_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
188 self.hooks.register_lifecycle(hook);
189 }
190
191 pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
193 self.hooks.register_pre_tool(hook);
194 }
195
196 pub fn register_post_tool_hook(&mut self, hook: Arc<dyn PostToolHook>) {
198 self.hooks.register_post_tool(hook);
199 }
200}
201
202pub struct PluginRegistry {
206 plugins: HashMap<String, Arc<dyn Plugin>>,
207 hooks: HookManager,
208}
209
210impl PluginRegistry {
211 pub fn new() -> Self {
212 Self {
213 plugins: HashMap::new(),
214 hooks: HookManager::new(),
215 }
216 }
217
218 pub fn ingest_host_plugins(&mut self, workspace: &std::path::Path, extra: &[PathBuf]) {
220 let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
221 for p in found {
222 tracing::info!(
223 "[plugin-host] {:?} {} {:?}",
224 p.kind,
225 p.name.as_deref().unwrap_or("?"),
226 p.path
227 );
228 }
229 }
230
231 pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
233 let name = plugin.name().to_string();
234 let mut ctx = PluginContext::default();
235 plugin.on_register(&mut ctx).await;
236 for tool in ctx.tools {
238 tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
239 }
240 self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
242 self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
243 self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
244 self.plugins.insert(name, plugin);
245 }
246
247 pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
249 self.plugins.insert(plugin.name().to_string(), plugin);
250 }
251
252 pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
254 self.hooks.register_lifecycle(hook);
255 }
256
257 pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
259 self.hooks.register_pre_tool(hook);
260 }
261
262 pub fn hooks(&self) -> &HookManager {
264 &self.hooks
265 }
266
267 pub async fn call(
269 &self,
270 plugin: &str,
271 method: &str,
272 params: Value,
273 ) -> Result<Value, PluginError> {
274 let p = self
275 .plugins
276 .get(plugin)
277 .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
278 p.call(method, params).await
279 }
280
281 pub fn list(&self) -> Vec<String> {
283 self.plugins.keys().cloned().collect()
284 }
285
286 pub fn info(&self, name: &str) -> Option<PluginInfo> {
288 self.plugins.get(name).map(|p| PluginInfo {
289 name: p.name().to_string(),
290 version: p.version().to_string(),
291 methods: p.methods(),
292 })
293 }
294
295 pub async fn emit(&self, event: &LifecycleEvent) {
297 self.hooks.emit(event).await;
298 }
299
300 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
302 self.hooks.check_pre_tool(name, arguments).await
303 }
304
305 pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
307 self.hooks.notify_post_tool(name, arguments, result).await;
308 }
309}
310
311impl Default for PluginRegistry {
312 fn default() -> Self {
313 Self::new()
314 }
315}
316
317pub struct ShellPlugin {
321 policy: crate::policy::ExecutionPolicy,
322}
323
324impl ShellPlugin {
325 pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
326 Self { policy }
327 }
328}
329
330#[async_trait]
331impl Plugin for ShellPlugin {
332 fn name(&self) -> &str {
333 "tools"
334 }
335
336 fn version(&self) -> &str {
337 "0.1.0"
338 }
339
340 fn methods(&self) -> Vec<MethodSpec> {
341 vec![MethodSpec {
342 name: "shell".to_string(),
343 description: "Execute shell command".to_string(),
344 params: {
345 let mut m = HashMap::new();
346 m.insert("cmd".to_string(), "string".to_string());
347 m
348 },
349 returns: "string".to_string(),
350 }]
351 }
352
353 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
354 if method != "shell" {
355 return Err(PluginError::new(-32601, "Method not found"));
356 }
357 if !self.policy.allow_plugin_shell {
358 return Err(PluginError::new(
359 -32604,
360 "Plugin shell execution is disabled by policy",
361 ));
362 }
363
364 let cmd = params
365 .get("cmd")
366 .and_then(|v| v.as_str())
367 .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
368
369 if let Err(reason) = self.policy.check_shell_command(cmd) {
370 return Err(PluginError::new(-32604, &reason));
371 }
372
373 match crate::process_cmd::run_argv_command(cmd, 120).await {
374 Ok((output, ok)) => Ok(serde_json::json!({
375 "stdout": output,
376 "success": ok,
377 })),
378 Err(e) => Err(PluginError::new(-32603, &e.to_string())),
379 }
380 }
381}
382
383#[derive(Debug, Serialize, Deserialize)]
386pub struct PluginInfo {
387 pub name: String,
388 pub version: String,
389 pub methods: Vec<MethodSpec>,
390}
391
392pub struct SessionNoteLifecycleHook {
396 workspace: std::path::PathBuf,
397}
398
399impl SessionNoteLifecycleHook {
400 pub fn new(workspace: std::path::PathBuf) -> Self {
401 Self { workspace }
402 }
403}
404
405#[async_trait]
406impl LifecycleHook for SessionNoteLifecycleHook {
407 fn name(&self) -> &str {
408 "session_note"
409 }
410
411 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
412 if let LifecycleEvent::AgentDone(chat_id, response) = event {
413 let redacted = crate::redaction::redact_text(response);
414 let preview: String = redacted.chars().take(200).collect();
415 if !preview.is_empty() {
416 let _ = crate::memory::session_note::append_session_note(
417 &self.workspace,
418 chat_id,
419 &preview,
420 );
421 }
422 }
423 Ok(())
424 }
425}
426
427pub struct LoggingLifecycleHook;
429
430#[async_trait]
431impl LifecycleHook for LoggingLifecycleHook {
432 fn name(&self) -> &str {
433 "logging"
434 }
435
436 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
437 match event {
438 LifecycleEvent::BeforeToolCall(name, args) => {
439 tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
440 }
441 LifecycleEvent::AfterToolCall(name, _args, result) => {
442 tracing::debug!(
443 "[hook] after_tool {} is_error:{} len:{}",
444 name,
445 result.is_error,
446 result.output.len()
447 );
448 }
449 LifecycleEvent::SessionStart(id) => {
450 tracing::info!("[hook] session_start {}", id);
451 }
452 LifecycleEvent::SessionEnd(id) => {
453 tracing::info!("[hook] session_end {}", id);
454 }
455 LifecycleEvent::AgentStart(chat_id, msg) => {
456 tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
457 }
458 LifecycleEvent::AgentDone(chat_id, response) => {
459 tracing::debug!(
460 "[hook] agent_done {} response_len:{}",
461 chat_id,
462 response.len()
463 );
464 }
465 }
466 Ok(())
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use serde_json::json;
474
475 struct TestPlugin;
476
477 #[async_trait]
478 impl Plugin for TestPlugin {
479 fn name(&self) -> &str {
480 "test"
481 }
482
483 fn version(&self) -> &str {
484 "0.1.0"
485 }
486
487 fn methods(&self) -> Vec<MethodSpec> {
488 vec![MethodSpec {
489 name: "echo".to_string(),
490 description: "Echo input".to_string(),
491 params: HashMap::new(),
492 returns: "object".to_string(),
493 }]
494 }
495
496 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
497 match method {
498 "echo" => Ok(json!({ "result": params })),
499 _ => Err(PluginError::new(-32601, "Method not found")),
500 }
501 }
502 }
503
504 #[tokio::test]
505 async fn shell_plugin_uses_argv_not_shell_when_allowed() {
506 let policy = crate::policy::ExecutionPolicy {
507 allow_plugin_shell: true,
508 ..Default::default()
509 };
510 let plugin = ShellPlugin::new(policy);
511 let result = plugin
512 .call("shell", json!({ "cmd": "echo plugin_ok" }))
513 .await
514 .unwrap();
515 assert_eq!(result["success"], true);
516 assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
517 }
518
519 #[tokio::test]
520 async fn shell_plugin_denied_when_policy_off() {
521 let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
522 allow_plugin_shell: false,
523 ..Default::default()
524 });
525 let err = plugin
526 .call("shell", json!({ "cmd": "echo x" }))
527 .await
528 .unwrap_err();
529 assert_eq!(err.code, -32604);
530 }
531
532 #[tokio::test]
533 async fn test_plugin_call() {
534 let mut registry = PluginRegistry::new();
535 registry.register_sync(Arc::new(TestPlugin));
536
537 let result = registry
538 .call("test", "echo", json!({ "code": "fn main() {}" }))
539 .await
540 .unwrap();
541
542 assert!(result.get("result").is_some());
543 }
544
545 #[tokio::test]
546 async fn test_hook_manager_emit() {
547 let mut manager = HookManager::new();
548 manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
549 let _ = manager.check_pre_tool("test", "{}").await;
550 manager
551 .notify_post_tool("test", "{}", &ToolResult::success("ok"))
552 .await;
553 }
555
556 #[tokio::test]
557 async fn test_plugin_context_register_tool() {
558 let mut ctx = PluginContext::new();
559 ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
561 assert!(ctx.hooks.lifecycle_hooks.len() == 1);
562 }
563}