Skip to main content

oxi/extensions/
mod.rs

1//! Extension system for oxi
2//!
3//! Extensions allow custom tools, commands, and event hooks to be loaded dynamically at runtime.
4
5pub mod context;
6#[allow(missing_docs)]
7pub mod ext_cli;
8pub mod loading;
9pub mod registry;
10pub mod stale;
11pub mod types;
12#[allow(missing_docs)]
13pub mod wasm;
14pub mod wasm_hooks;
15pub mod wasm_tool;
16
17// Re-export types from submodules
18pub use crate::extensions::context::{ExtensionContext, ExtensionContextBuilder};
19pub use crate::extensions::loading::{
20    discover_extensions, discover_extensions_in_dir, load_extension, load_extensions,
21    validate_extension, ValidatedExtension, SHARED_LIB_EXTENSION,
22};
23pub use crate::extensions::registry::{ExtensionErrorHandle, ExtensionRegistry, ExtensionRunner};
24pub use crate::extensions::types::{
25    AfterProviderResponseEvent, BashEvent, BeforeProviderRequestEvent, Command, ContextEmitResult,
26    ContextEvent, ExtensionError, ExtensionErrorListener, ExtensionErrorRecord, ExtensionManifest,
27    ExtensionPermission, ExtensionState, InputEvent, InputEventResult, InputSource,
28    ModelSelectEvent, ModelSelectSource, ProviderRequestEmitResult, SessionBeforeCompactEvent,
29    SessionBeforeEmitResult, SessionBeforeForkEvent, SessionBeforeSwitchEvent,
30    SessionBeforeTreeEvent, SessionCompactEvent, SessionShutdownEvent, SessionShutdownReason,
31    SessionSwitchReason, SessionTreeEvent, ThinkingLevelSelectEvent, ToolCallEmitResult,
32    ToolResultEmitResult,
33};
34pub use crate::extensions::wasm::{
35    ExtensionInfo, WasmCommandDef, WasmExtensionManager, WasmToolDef,
36};
37pub use crate::extensions::wasm_tool::WasmTool;
38
39// Re-export from oxi-agent
40pub use oxi_agent::{AgentEvent, AgentTool, AgentToolResult};
41
42/// A keyboard shortcut registered by an extension.
43#[derive(Debug, Clone)]
44pub struct ExtensionShortcut {
45    /// Key combination (e.g. "ctrl+shift+x")
46    pub key: String,
47    /// Human-readable description
48    pub description: String,
49    /// Action identifier (used to dispatch events)
50    pub action: String,
51}
52
53// The Extension trait
54/// Core trait that every oxi extension must implement.
55pub trait Extension: Send + Sync {
56    /// Returns the extension's unique identifier.
57    fn name(&self) -> &str;
58    /// Returns a human-readable description of what this extension does.
59    fn description(&self) -> &str;
60    /// Returns the extension manifest containing metadata and permissions.
61    /// Default implementation constructs a minimal manifest from [`name()`](Extension::name) and
62    /// [`description()`](Extension::description).
63    fn manifest(&self) -> ExtensionManifest {
64        ExtensionManifest::new(self.name(), "0.0.0").with_description(self.description())
65    }
66    /// Registers custom tools exposed by this extension.
67    /// Each returned tool becomes available to the agent at runtime.
68    /// Default returns an empty vector (no custom tools).
69    fn register_tools(&self) -> Vec<std::sync::Arc<dyn oxi_agent::AgentTool>> {
70        vec![]
71    }
72    /// Registers slash commands exposed by this extension.
73    /// Each returned command becomes available as `/<name>` in the input field.
74    /// Default returns an empty vector (no custom commands).
75    fn register_commands(&self) -> Vec<Command> {
76        vec![]
77    }
78    /// Called once when the extension is first loaded into a session.
79    /// Use this to initialize resources, read config, or register with external services.
80    fn on_load(&self, _ctx: &ExtensionContext) {}
81    /// Called once when the extension is unloaded or the session ends.
82    /// Use this to clean up resources allocated in [`on_load()`](Extension::on_load).
83    fn on_unload(&self) {}
84    /// Called after the agent sends a message to the LLM.
85    /// `_msg` is the raw message content string.
86    fn on_message_sent(&self, _msg: &str) {}
87    /// Called after receiving a response from the LLM.
88    /// `_msg` is the raw response content string.
89    fn on_message_received(&self, _msg: &str) {}
90    /// Called immediately before a tool is executed.
91    /// `_tool` is the tool name and `_params` are the JSON arguments.
92    fn on_tool_call(&self, _tool: &str, _params: &serde_json::Value) {}
93    /// Called immediately after a tool execution completes.
94    /// `_tool` is the tool name and `_result` contains the output or error.
95    fn on_tool_result(&self, _tool: &str, _result: &oxi_agent::AgentToolResult) {}
96    /// Called when a new session starts.
97    /// `_session_id` uniquely identifies the session.
98    fn on_session_start(&self, _session_id: &str) {}
99    /// Called when a session ends.
100    /// `_session_id` uniquely identifies the session that ended.
101    fn on_session_end(&self, _session_id: &str) {}
102    /// Called whenever the user saves or updates settings.
103    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {}
104    /// Catch-all hook for any agent event not covered by a specific method.
105    fn on_event(&self, _event: &oxi_agent::AgentEvent) {}
106    /// Called before a tool is executed. Return `Err` to block the tool call.
107    fn on_before_tool_call(
108        &self,
109        _tool: &str,
110        _args: &serde_json::Value,
111    ) -> Result<(), anyhow::Error> {
112        Ok(())
113    }
114    /// Called after a tool completes. Return `Err` to surface an error to the agent.
115    fn on_after_tool_call(
116        &self,
117        _tool: &str,
118        _result: &oxi_agent::AgentToolResult,
119    ) -> Result<(), anyhow::Error> {
120        Ok(())
121    }
122    /// Called before the context window is compacted. Return `Err` to abort compaction.
123    fn on_before_compaction(&self, _ctx: &crate::CompactionContext) -> Result<(), anyhow::Error> {
124        Ok(())
125    }
126    /// Called after the context window is compacted with the generated summary.
127    fn on_after_compaction(&self, _summary: &str) -> Result<(), anyhow::Error> {
128        Ok(())
129    }
130    /// Called when any error occurs in the agent loop.
131    fn on_error(&self, _error: &anyhow::Error) -> Result<(), anyhow::Error> {
132        Ok(())
133    }
134    /// Called before the active session switches to a different branch or parent.
135    fn session_before_switch(
136        &self,
137        _event: &crate::extensions::types::SessionBeforeSwitchEvent,
138    ) -> Result<(), anyhow::Error> {
139        Ok(())
140    }
141    /// Called before a session is forked (branched) into a new subtree.
142    fn session_before_fork(
143        &self,
144        _event: &crate::extensions::types::SessionBeforeForkEvent,
145    ) -> Result<(), anyhow::Error> {
146        Ok(())
147    }
148    /// Called before the context window is compacted.
149    fn session_before_compact(
150        &self,
151        _event: &crate::extensions::types::SessionBeforeCompactEvent,
152    ) -> Result<(), anyhow::Error> {
153        Ok(())
154    }
155    /// Called when the context window is being compacted.
156    fn session_compact(
157        &self,
158        _event: &crate::extensions::types::SessionCompactEvent,
159    ) -> Result<(), anyhow::Error> {
160        Ok(())
161    }
162    /// Called when a session is shutting down.
163    fn session_shutdown(&self, _event: &crate::extensions::types::SessionShutdownEvent) {}
164    /// Called before a tree navigation action (branch listing, traversal, etc.).
165    fn session_before_tree(
166        &self,
167        _event: &crate::extensions::types::SessionBeforeTreeEvent,
168    ) -> Result<(), anyhow::Error> {
169        Ok(())
170    }
171    /// Called during a tree navigation action.
172    fn session_tree(&self, _event: &crate::extensions::types::SessionTreeEvent) {}
173    /// Emits into the agent context. Return `Err` to signal that the event was handled.
174    fn context(
175        &self,
176        _event: &mut crate::extensions::types::ContextEvent,
177    ) -> Result<(), anyhow::Error> {
178        Ok(())
179    }
180    /// Called before every LLM provider request. Allows the extension to mutate
181    /// the request parameters (model, temperature, tools, etc.).
182    fn before_provider_request(
183        &self,
184        _event: &mut crate::extensions::types::BeforeProviderRequestEvent,
185    ) -> Result<(), anyhow::Error> {
186        Ok(())
187    }
188    /// Called after every LLM provider response. Allows the extension to read or
189    /// annotate the response before it is processed by the agent loop.
190    fn after_provider_response(
191        &self,
192        _event: &crate::extensions::types::AfterProviderResponseEvent,
193    ) -> Result<(), anyhow::Error> {
194        Ok(())
195    }
196    /// Called when the user or agent selects a model (via `/model` or auto-routing).
197    fn model_select(&self, _event: &crate::extensions::types::ModelSelectEvent) {}
198    /// Called when the thinking level is changed.
199    fn thinking_level_select(&self, _event: &crate::extensions::types::ThinkingLevelSelectEvent) {}
200    /// Called when a bash command is about to be executed.
201    fn bash(&self, _event: &crate::extensions::types::BashEvent) {}
202    /// Called for every user input keystroke. Return
203    /// [`InputEventResult::Handled`] to suppress the default input handling, or
204    /// [`InputEventResult::Transform { text }`] to replace the input text.
205    fn input(
206        &self,
207        _event: &crate::extensions::types::InputEvent,
208    ) -> crate::extensions::types::InputEventResult {
209        crate::extensions::types::InputEventResult::Continue
210    }
211    /// Registers keyboard shortcuts exposed by this extension.
212    /// Default returns an empty vector (no shortcuts).
213    fn register_shortcuts(&self) -> Vec<ExtensionShortcut> {
214        vec![]
215    }
216}
217
218// Built-in "noop" extension
219/// pub.
220pub struct NoopExtension;
221impl Extension for NoopExtension {
222    fn name(&self) -> &str {
223        "noop"
224    }
225    fn description(&self) -> &str {
226        "Built-in no-op extension"
227    }
228}
229
230// Test helpers
231#[cfg(test)]
232pub struct RecordingExtension {
233    pub name: String,
234    pub calls: std::sync::Mutex<Vec<String>>,
235}
236#[cfg(test)]
237impl RecordingExtension {
238    pub fn new(name: impl Into<String>) -> Self {
239        Self {
240            name: name.into(),
241            calls: std::sync::Mutex::new(Vec::new()),
242        }
243    }
244    pub fn push(&self, call: &str) {
245        self.calls.lock().unwrap().push(call.to_string());
246    }
247    pub fn calls(&self) -> Vec<String> {
248        self.calls.lock().unwrap().clone()
249    }
250}
251#[cfg(test)]
252impl Extension for RecordingExtension {
253    fn name(&self) -> &str {
254        &self.name
255    }
256    fn description(&self) -> &str {
257        "recording test extension"
258    }
259    fn on_load(&self, _ctx: &ExtensionContext) {
260        self.push("on_load");
261    }
262    fn on_unload(&self) {
263        self.push("on_unload");
264    }
265    fn on_message_sent(&self, msg: &str) {
266        self.push(&format!("on_message_sent({})", msg));
267    }
268    fn on_message_received(&self, msg: &str) {
269        self.push(&format!("on_message_received({})", msg));
270    }
271    fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
272        self.push(&format!("on_tool_call({})", tool));
273    }
274    fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
275        self.push(&format!("on_tool_result({})", tool));
276    }
277    fn on_session_start(&self, session_id: &str) {
278        self.push(&format!("on_session_start({})", session_id));
279    }
280    fn on_session_end(&self, session_id: &str) {
281        self.push(&format!("on_session_end({})", session_id));
282    }
283    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
284        self.push("on_settings_changed");
285    }
286    fn on_event(&self, _event: &oxi_agent::AgentEvent) {
287        self.push("on_event");
288    }
289}
290
291// Tests
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use oxi_store::settings::Settings;
296    use std::sync::Arc;
297
298    #[test]
299    fn test_manifest_builder() {
300        let manifest = ExtensionManifest::new("my-ext", "1.0.0")
301            .with_description("A test extension")
302            .with_author("test-author")
303            .with_permission(ExtensionPermission::FileRead)
304            .with_permission(ExtensionPermission::Bash)
305            .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));
306
307        assert_eq!(manifest.name, "my-ext");
308        assert_eq!(manifest.version, "1.0.0");
309        assert_eq!(manifest.description, "A test extension");
310        assert_eq!(manifest.author, "test-author");
311        assert!(manifest.has_permission(ExtensionPermission::FileRead));
312        assert!(manifest.has_permission(ExtensionPermission::Bash));
313        assert!(!manifest.has_permission(ExtensionPermission::Network));
314    }
315
316    #[test]
317    fn test_permission_display() {
318        assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
319        assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
320    }
321
322    #[test]
323    fn test_context_builder_minimal() {
324        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
325        assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
326        assert!(ctx.session_id.is_none());
327        assert!(ctx.is_idle());
328    }
329
330    #[test]
331    fn test_context_builder_full() {
332        use parking_lot::RwLock;
333        let settings = Arc::new(RwLock::new(Settings::default()));
334        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
335            .settings(settings)
336            .config(serde_json::json!({"key": "value"}))
337            .session_id("sess-123")
338            .build();
339
340        assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
341        assert_eq!(ctx.session_id, Some("sess-123".to_string()));
342        assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
343    }
344
345    #[test]
346    fn test_registry_register_and_collect() {
347        let mut reg = ExtensionRegistry::new();
348        reg.register(Arc::new(NoopExtension));
349        assert_eq!(reg.len(), 1);
350        assert!(!reg.is_empty());
351    }
352
353    #[test]
354    fn test_registry_enable_disable() {
355        let mut reg = ExtensionRegistry::new();
356        let ext = Arc::new(RecordingExtension::new("rec"));
357        reg.register(ext);
358        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
359        assert!(reg.is_enabled("rec"));
360        reg.disable("rec").unwrap();
361        assert!(!reg.is_enabled("rec"));
362        reg.enable("rec", &ctx).unwrap();
363        assert!(reg.is_enabled("rec"));
364    }
365
366    #[test]
367    fn test_emit_load() {
368        let mut reg = ExtensionRegistry::new();
369        let ext = Arc::new(RecordingExtension::new("rec"));
370        reg.register(ext.clone());
371        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
372        reg.emit_load(&ctx);
373        assert_eq!(ext.calls(), vec!["on_load"]);
374    }
375
376    #[test]
377    fn test_graceful_degradation_on_panic() {
378        struct PanickingExtension;
379        impl Extension for PanickingExtension {
380            fn name(&self) -> &str {
381                "panicker"
382            }
383            fn description(&self) -> &str {
384                "Panics"
385            }
386            fn on_load(&self, _ctx: &ExtensionContext) {
387                panic!("intentional panic in on_load");
388            }
389            fn on_message_sent(&self, _msg: &str) {
390                panic!("intentional panic in on_message_sent");
391            }
392        }
393
394        let mut reg = ExtensionRegistry::new();
395        reg.register(Arc::new(PanickingExtension));
396        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
397        reg.emit_load(&ctx);
398        reg.emit_message_sent("hello");
399        let errors = reg.errors();
400        assert_eq!(errors.len(), 2);
401    }
402
403    #[test]
404    fn test_extension_state_display() {
405        assert_eq!(ExtensionState::Pending.to_string(), "pending");
406        assert_eq!(ExtensionState::Active.to_string(), "active");
407    }
408
409    #[test]
410    fn test_tool_call_emit_result_default() {
411        let result = ToolCallEmitResult::default();
412        assert!(!result.blocked);
413        assert!(result.errors.is_empty());
414    }
415
416    #[test]
417    fn test_runner_new() {
418        let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
419        assert!(runner.is_empty());
420        assert_eq!(runner.len(), 0);
421    }
422}