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 match crate::process_cmd::run_argv_command(cmd, 120).await {
370 Ok((output, ok)) => Ok(serde_json::json!({
371 "stdout": output,
372 "success": ok,
373 })),
374 Err(e) => Err(PluginError::new(-32603, &e.to_string())),
375 }
376 }
377}
378
379#[derive(Debug, Serialize, Deserialize)]
382pub struct PluginInfo {
383 pub name: String,
384 pub version: String,
385 pub methods: Vec<MethodSpec>,
386}
387
388pub struct SessionNoteLifecycleHook {
392 workspace: std::path::PathBuf,
393}
394
395impl SessionNoteLifecycleHook {
396 pub fn new(workspace: std::path::PathBuf) -> Self {
397 Self { workspace }
398 }
399}
400
401#[async_trait]
402impl LifecycleHook for SessionNoteLifecycleHook {
403 fn name(&self) -> &str {
404 "session_note"
405 }
406
407 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
408 if let LifecycleEvent::AgentDone(chat_id, response) = event {
409 let redacted = crate::redaction::redact_text(response);
410 let preview: String = redacted.chars().take(200).collect();
411 if !preview.is_empty() {
412 let _ = crate::memory::session_note::append_session_note(
413 &self.workspace,
414 chat_id,
415 &preview,
416 );
417 }
418 }
419 Ok(())
420 }
421}
422
423pub struct LoggingLifecycleHook;
425
426#[async_trait]
427impl LifecycleHook for LoggingLifecycleHook {
428 fn name(&self) -> &str {
429 "logging"
430 }
431
432 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
433 match event {
434 LifecycleEvent::BeforeToolCall(name, args) => {
435 tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
436 }
437 LifecycleEvent::AfterToolCall(name, _args, result) => {
438 tracing::debug!(
439 "[hook] after_tool {} is_error:{} len:{}",
440 name,
441 result.is_error,
442 result.output.len()
443 );
444 }
445 LifecycleEvent::SessionStart(id) => {
446 tracing::info!("[hook] session_start {}", id);
447 }
448 LifecycleEvent::SessionEnd(id) => {
449 tracing::info!("[hook] session_end {}", id);
450 }
451 LifecycleEvent::AgentStart(chat_id, msg) => {
452 tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
453 }
454 LifecycleEvent::AgentDone(chat_id, response) => {
455 tracing::debug!(
456 "[hook] agent_done {} response_len:{}",
457 chat_id,
458 response.len()
459 );
460 }
461 }
462 Ok(())
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use serde_json::json;
470
471 struct TestPlugin;
472
473 #[async_trait]
474 impl Plugin for TestPlugin {
475 fn name(&self) -> &str {
476 "test"
477 }
478
479 fn version(&self) -> &str {
480 "0.1.0"
481 }
482
483 fn methods(&self) -> Vec<MethodSpec> {
484 vec![MethodSpec {
485 name: "echo".to_string(),
486 description: "Echo input".to_string(),
487 params: HashMap::new(),
488 returns: "object".to_string(),
489 }]
490 }
491
492 async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
493 match method {
494 "echo" => Ok(json!({ "result": params })),
495 _ => Err(PluginError::new(-32601, "Method not found")),
496 }
497 }
498 }
499
500 #[tokio::test]
501 async fn shell_plugin_uses_argv_not_shell_when_allowed() {
502 let policy = crate::policy::ExecutionPolicy {
503 allow_plugin_shell: true,
504 ..Default::default()
505 };
506 let plugin = ShellPlugin::new(policy);
507 let result = plugin
508 .call("shell", json!({ "cmd": "echo plugin_ok" }))
509 .await
510 .unwrap();
511 assert_eq!(result["success"], true);
512 assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
513 }
514
515 #[tokio::test]
516 async fn shell_plugin_denied_when_policy_off() {
517 let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
518 allow_plugin_shell: false,
519 ..Default::default()
520 });
521 let err = plugin
522 .call("shell", json!({ "cmd": "echo x" }))
523 .await
524 .unwrap_err();
525 assert_eq!(err.code, -32604);
526 }
527
528 #[tokio::test]
529 async fn test_plugin_call() {
530 let mut registry = PluginRegistry::new();
531 registry.register_sync(Arc::new(TestPlugin));
532
533 let result = registry
534 .call("test", "echo", json!({ "code": "fn main() {}" }))
535 .await
536 .unwrap();
537
538 assert!(result.get("result").is_some());
539 }
540
541 #[tokio::test]
542 async fn test_hook_manager_emit() {
543 let mut manager = HookManager::new();
544 manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
545 let _ = manager.check_pre_tool("test", "{}").await;
546 manager
547 .notify_post_tool("test", "{}", &ToolResult::success("ok"))
548 .await;
549 }
551
552 #[tokio::test]
553 async fn test_plugin_context_register_tool() {
554 let mut ctx = PluginContext::new();
555 ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
557 assert!(ctx.hooks.lifecycle_hooks.len() == 1);
558 }
559}