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 hooks(&self) -> &HookManager {
259 &self.hooks
260 }
261
262 pub async fn call(
264 &self,
265 plugin: &str,
266 method: &str,
267 params: Value,
268 ) -> Result<Value, PluginError> {
269 let p = self
270 .plugins
271 .get(plugin)
272 .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
273 p.call(method, params).await
274 }
275
276 pub fn list(&self) -> Vec<String> {
278 self.plugins.keys().cloned().collect()
279 }
280
281 pub fn info(&self, name: &str) -> Option<PluginInfo> {
283 self.plugins.get(name).map(|p| PluginInfo {
284 name: p.name().to_string(),
285 version: p.version().to_string(),
286 methods: p.methods(),
287 })
288 }
289
290 pub async fn emit(&self, event: &LifecycleEvent) {
292 self.hooks.emit(event).await;
293 }
294
295 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
297 self.hooks.check_pre_tool(name, arguments).await
298 }
299
300 pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
302 self.hooks.notify_post_tool(name, arguments, result).await;
303 }
304}
305
306impl Default for PluginRegistry {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312pub struct ShellPlugin {
316 policy: crate::policy::ExecutionPolicy,
317}
318
319impl ShellPlugin {
320 pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
321 Self { policy }
322 }
323}
324
325#[async_trait]
326impl Plugin for ShellPlugin {
327 fn name(&self) -> &str {
328 "tools"
329 }
330
331 fn version(&self) -> &str {
332 "0.1.0"
333 }
334
335 fn methods(&self) -> Vec<MethodSpec> {
336 vec![MethodSpec {
337 name: "shell".to_string(),
338 description: "Execute shell command".to_string(),
339 params: {
340 let mut m = HashMap::new();
341 m.insert("cmd".to_string(), "string".to_string());
342 m
343 },
344 returns: "string".to_string(),
345 }]
346 }
347
348 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
349 if method != "shell" {
350 return Err(PluginError::new(-32601, "Method not found"));
351 }
352 if !self.policy.allow_plugin_shell {
353 return Err(PluginError::new(
354 -32604,
355 "Plugin shell execution is disabled by policy",
356 ));
357 }
358
359 let cmd = params
360 .get("cmd")
361 .and_then(|v| v.as_str())
362 .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
363
364 match crate::process_cmd::run_argv_command(cmd, 120).await {
365 Ok((output, ok)) => Ok(serde_json::json!({
366 "stdout": output,
367 "success": ok,
368 })),
369 Err(e) => Err(PluginError::new(-32603, &e.to_string())),
370 }
371 }
372}
373
374#[derive(Debug, Serialize, Deserialize)]
377pub struct PluginInfo {
378 pub name: String,
379 pub version: String,
380 pub methods: Vec<MethodSpec>,
381}
382
383pub struct SessionNoteLifecycleHook {
387 workspace: std::path::PathBuf,
388}
389
390impl SessionNoteLifecycleHook {
391 pub fn new(workspace: std::path::PathBuf) -> Self {
392 Self { workspace }
393 }
394}
395
396#[async_trait]
397impl LifecycleHook for SessionNoteLifecycleHook {
398 fn name(&self) -> &str {
399 "session_note"
400 }
401
402 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
403 if let LifecycleEvent::AgentDone(chat_id, response) = event {
404 let redacted = crate::redaction::redact_text(response);
405 let preview: String = redacted.chars().take(200).collect();
406 if !preview.is_empty() {
407 let _ = crate::memory::session_note::append_session_note(
408 &self.workspace,
409 chat_id,
410 &preview,
411 );
412 }
413 }
414 Ok(())
415 }
416}
417
418pub struct LoggingLifecycleHook;
420
421#[async_trait]
422impl LifecycleHook for LoggingLifecycleHook {
423 fn name(&self) -> &str {
424 "logging"
425 }
426
427 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
428 match event {
429 LifecycleEvent::BeforeToolCall(name, args) => {
430 tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
431 }
432 LifecycleEvent::AfterToolCall(name, _args, result) => {
433 tracing::debug!(
434 "[hook] after_tool {} is_error:{} len:{}",
435 name,
436 result.is_error,
437 result.output.len()
438 );
439 }
440 LifecycleEvent::SessionStart(id) => {
441 tracing::info!("[hook] session_start {}", id);
442 }
443 LifecycleEvent::SessionEnd(id) => {
444 tracing::info!("[hook] session_end {}", id);
445 }
446 LifecycleEvent::AgentStart(chat_id, msg) => {
447 tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
448 }
449 LifecycleEvent::AgentDone(chat_id, response) => {
450 tracing::debug!(
451 "[hook] agent_done {} response_len:{}",
452 chat_id,
453 response.len()
454 );
455 }
456 }
457 Ok(())
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use serde_json::json;
465
466 struct TestPlugin;
467
468 #[async_trait]
469 impl Plugin for TestPlugin {
470 fn name(&self) -> &str {
471 "test"
472 }
473
474 fn version(&self) -> &str {
475 "0.1.0"
476 }
477
478 fn methods(&self) -> Vec<MethodSpec> {
479 vec![MethodSpec {
480 name: "echo".to_string(),
481 description: "Echo input".to_string(),
482 params: HashMap::new(),
483 returns: "object".to_string(),
484 }]
485 }
486
487 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
488 match method {
489 "echo" => Ok(json!({ "result": params })),
490 _ => Err(PluginError::new(-32601, "Method not found")),
491 }
492 }
493 }
494
495 #[tokio::test]
496 async fn shell_plugin_uses_argv_not_shell_when_allowed() {
497 let policy = crate::policy::ExecutionPolicy {
498 allow_plugin_shell: true,
499 ..Default::default()
500 };
501 let plugin = ShellPlugin::new(policy);
502 let result = plugin
503 .call("shell", json!({ "cmd": "echo plugin_ok" }))
504 .await
505 .unwrap();
506 assert_eq!(result["success"], true);
507 assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
508 }
509
510 #[tokio::test]
511 async fn shell_plugin_denied_when_policy_off() {
512 let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
513 allow_plugin_shell: false,
514 ..Default::default()
515 });
516 let err = plugin
517 .call("shell", json!({ "cmd": "echo x" }))
518 .await
519 .unwrap_err();
520 assert_eq!(err.code, -32604);
521 }
522
523 #[tokio::test]
524 async fn test_plugin_call() {
525 let mut registry = PluginRegistry::new();
526 registry.register_sync(Arc::new(TestPlugin));
527
528 let result = registry
529 .call("test", "echo", json!({ "code": "fn main() {}" }))
530 .await
531 .unwrap();
532
533 assert!(result.get("result").is_some());
534 }
535
536 #[tokio::test]
537 async fn test_hook_manager_emit() {
538 let mut manager = HookManager::new();
539 manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
540 let _ = manager.check_pre_tool("test", "{}").await;
541 manager
542 .notify_post_tool("test", "{}", &ToolResult::success("ok"))
543 .await;
544 }
546
547 #[tokio::test]
548 async fn test_plugin_context_register_tool() {
549 let mut ctx = PluginContext::new();
550 ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
552 assert!(ctx.hooks.lifecycle_hooks.len() == 1);
553 }
554}