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`](crate::extensions::types::InputEventResult::Handled)
204    /// to suppress the default input handling, or
205    /// [`InputEventResult::Transform { text }`](crate::extensions::types::InputEventResult::Transform)
206    /// to replace the input text.
207    fn input(
208        &self,
209        _event: &crate::extensions::types::InputEvent,
210    ) -> crate::extensions::types::InputEventResult {
211        crate::extensions::types::InputEventResult::Continue
212    }
213    /// Registers keyboard shortcuts exposed by this extension.
214    /// Default returns an empty vector (no shortcuts).
215    fn register_shortcuts(&self) -> Vec<ExtensionShortcut> {
216        vec![]
217    }
218}
219
220// Built-in "noop" extension
221/// pub.
222pub struct NoopExtension;
223impl Extension for NoopExtension {
224    fn name(&self) -> &str {
225        "noop"
226    }
227    fn description(&self) -> &str {
228        "Built-in no-op extension"
229    }
230}
231
232// Test helpers
233#[cfg(test)]
234pub struct RecordingExtension {
235    pub name: String,
236    pub calls: std::sync::Mutex<Vec<String>>,
237}
238#[cfg(test)]
239impl RecordingExtension {
240    pub fn new(name: impl Into<String>) -> Self {
241        Self {
242            name: name.into(),
243            calls: std::sync::Mutex::new(Vec::new()),
244        }
245    }
246    pub fn push(&self, call: &str) {
247        self.calls.lock().unwrap().push(call.to_string());
248    }
249    pub fn calls(&self) -> Vec<String> {
250        self.calls.lock().unwrap().clone()
251    }
252}
253#[cfg(test)]
254impl Extension for RecordingExtension {
255    fn name(&self) -> &str {
256        &self.name
257    }
258    fn description(&self) -> &str {
259        "recording test extension"
260    }
261    fn on_load(&self, _ctx: &ExtensionContext) {
262        self.push("on_load");
263    }
264    fn on_unload(&self) {
265        self.push("on_unload");
266    }
267    fn on_message_sent(&self, msg: &str) {
268        self.push(&format!("on_message_sent({})", msg));
269    }
270    fn on_message_received(&self, msg: &str) {
271        self.push(&format!("on_message_received({})", msg));
272    }
273    fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
274        self.push(&format!("on_tool_call({})", tool));
275    }
276    fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
277        self.push(&format!("on_tool_result({})", tool));
278    }
279    fn on_session_start(&self, session_id: &str) {
280        self.push(&format!("on_session_start({})", session_id));
281    }
282    fn on_session_end(&self, session_id: &str) {
283        self.push(&format!("on_session_end({})", session_id));
284    }
285    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
286        self.push("on_settings_changed");
287    }
288    fn on_event(&self, _event: &oxi_agent::AgentEvent) {
289        self.push("on_event");
290    }
291}
292
293// Tests
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use oxi_store::settings::Settings;
298    use std::sync::Arc;
299
300    #[test]
301    fn test_manifest_builder() {
302        let manifest = ExtensionManifest::new("my-ext", "1.0.0")
303            .with_description("A test extension")
304            .with_author("test-author")
305            .with_permission(ExtensionPermission::FileRead)
306            .with_permission(ExtensionPermission::Bash)
307            .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));
308
309        assert_eq!(manifest.name, "my-ext");
310        assert_eq!(manifest.version, "1.0.0");
311        assert_eq!(manifest.description, "A test extension");
312        assert_eq!(manifest.author, "test-author");
313        assert!(manifest.has_permission(ExtensionPermission::FileRead));
314        assert!(manifest.has_permission(ExtensionPermission::Bash));
315        assert!(!manifest.has_permission(ExtensionPermission::Network));
316    }
317
318    #[test]
319    fn test_permission_display() {
320        assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
321        assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
322    }
323
324    #[test]
325    fn test_context_builder_minimal() {
326        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
327        assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
328        assert!(ctx.session_id.is_none());
329        assert!(ctx.is_idle());
330    }
331
332    #[test]
333    fn test_context_builder_full() {
334        use parking_lot::RwLock;
335        let settings = Arc::new(RwLock::new(Settings::default()));
336        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
337            .settings(settings)
338            .config(serde_json::json!({"key": "value"}))
339            .session_id("sess-123")
340            .build();
341
342        assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
343        assert_eq!(ctx.session_id, Some("sess-123".to_string()));
344        assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
345    }
346
347    #[test]
348    fn test_registry_register_and_collect() {
349        let mut reg = ExtensionRegistry::new();
350        reg.register(Arc::new(NoopExtension));
351        assert_eq!(reg.len(), 1);
352        assert!(!reg.is_empty());
353    }
354
355    #[test]
356    fn test_registry_enable_disable() {
357        let mut reg = ExtensionRegistry::new();
358        let ext = Arc::new(RecordingExtension::new("rec"));
359        reg.register(ext);
360        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
361        assert!(reg.is_enabled("rec"));
362        reg.disable("rec").unwrap();
363        assert!(!reg.is_enabled("rec"));
364        reg.enable("rec", &ctx).unwrap();
365        assert!(reg.is_enabled("rec"));
366    }
367
368    #[test]
369    fn test_emit_load() {
370        let mut reg = ExtensionRegistry::new();
371        let ext = Arc::new(RecordingExtension::new("rec"));
372        reg.register(ext.clone());
373        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
374        reg.emit_load(&ctx);
375        assert_eq!(ext.calls(), vec!["on_load"]);
376    }
377
378    #[test]
379    fn test_graceful_degradation_on_panic() {
380        struct PanickingExtension;
381        impl Extension for PanickingExtension {
382            fn name(&self) -> &str {
383                "panicker"
384            }
385            fn description(&self) -> &str {
386                "Panics"
387            }
388            fn on_load(&self, _ctx: &ExtensionContext) {
389                panic!("intentional panic in on_load");
390            }
391            fn on_message_sent(&self, _msg: &str) {
392                panic!("intentional panic in on_message_sent");
393            }
394        }
395
396        let mut reg = ExtensionRegistry::new();
397        reg.register(Arc::new(PanickingExtension));
398        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
399        reg.emit_load(&ctx);
400        reg.emit_message_sent("hello");
401        let errors = reg.errors();
402        assert_eq!(errors.len(), 2);
403    }
404
405    #[test]
406    fn test_extension_state_display() {
407        assert_eq!(ExtensionState::Pending.to_string(), "pending");
408        assert_eq!(ExtensionState::Active.to_string(), "active");
409    }
410
411    #[test]
412    fn test_tool_call_emit_result_default() {
413        let result = ToolCallEmitResult::default();
414        assert!(!result.blocked);
415        assert!(result.errors.is_empty());
416    }
417
418    #[test]
419    fn test_runner_new() {
420        let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
421        assert!(runner.is_empty());
422        assert_eq!(runner.len(), 0);
423    }
424}