Skip to main content

agent_engine/extensions/runtime/
mod.rs

1//! Extension runtime trait and registry.
2
3pub mod process;
4pub mod restart;
5
6pub use restart::RestartPolicy;
7
8use self::process::{
9    NotificationFrame, ProviderCompleteParams, ProviderCompleteResult, ProviderStreamEvent,
10};
11use crate::extensions::commands::CommandOutputEvent;
12use crate::extensions::hooks::events::{HookEvent, HookResult};
13use crate::extensions::info::PluginInfo;
14use crate::extensions::tasks::TaskEvent;
15use async_trait::async_trait;
16use serde_json::Value;
17
18/// Streamed event delivered to a `command.invoke` caller.
19#[derive(Debug, Clone, PartialEq)]
20pub enum InvokeCommandEvent {
21    /// Command output event matching the caller's `request_id`.
22    Output(CommandOutputEvent),
23    /// Spontaneous plugin task event (any `request_id`).
24    Task(TaskEvent),
25}
26
27/// Health state for an extension handler.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ExtensionHealth {
30    /// Manifest validated, process spawned, but `initialize` not yet completed.
31    Loaded,
32    /// Manifest failed validation — the extension never started.
33    FailedValidation,
34    /// `initialize` rpc failed — the extension started but couldn't capability-handshake.
35    FailedInitialize,
36    /// Healthy and serving requests.
37    Running,
38    /// Process restarting after transport failure but within restart budget.
39    Restarting,
40    /// Running, but at least one recent operation failed (e.g. hook timeout).
41    Degraded,
42    /// Permanent failure — restart budget exhausted or unrecoverable error.
43    Failed,
44}
45
46impl ExtensionHealth {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::Loaded => "loaded",
50            Self::FailedValidation => "failed_validation",
51            Self::FailedInitialize => "failed_initialize",
52            Self::Running => "running",
53            Self::Restarting => "restarting",
54            Self::Degraded => "degraded",
55            Self::Failed => "failed",
56        }
57    }
58}
59
60#[cfg(test)]
61#[allow(clippy::items_after_test_module)]
62mod health_tests {
63    use super::ExtensionHealth;
64
65    #[test]
66    fn as_str_covers_all_variants() {
67        assert_eq!(ExtensionHealth::Loaded.as_str(), "loaded");
68        assert_eq!(
69            ExtensionHealth::FailedValidation.as_str(),
70            "failed_validation"
71        );
72        assert_eq!(
73            ExtensionHealth::FailedInitialize.as_str(),
74            "failed_initialize"
75        );
76        assert_eq!(ExtensionHealth::Running.as_str(), "running");
77        assert_eq!(ExtensionHealth::Restarting.as_str(), "restarting");
78        assert_eq!(ExtensionHealth::Degraded.as_str(), "degraded");
79        assert_eq!(ExtensionHealth::Failed.as_str(), "failed");
80    }
81}
82
83/// A point-in-time lifecycle observation, not a lease on future execution.
84///
85/// Generations are opaque and local to one handler instance. A caller must pin
86/// both the handler identity and generation before dispatch, then require the
87/// same generation with `Running` or `Degraded` health when accepting a reply
88/// and before executing any deferred work. All other health states fail closed.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct ExtensionLifecycle {
91    pub generation: u64,
92    pub health: ExtensionHealth,
93}
94
95/// Trait for extension runtimes that can handle hook events.
96#[async_trait]
97pub trait ExtensionHandler: Send + Sync {
98    /// Unique identifier for this extension.
99    fn id(&self) -> &str;
100
101    /// Observe the actual serving runtime without waiting for locks or RPC I/O.
102    ///
103    /// `None` means unsupported or temporarily unavailable, never "healthy".
104    /// Implementations must invalidate the generation on runtime/transport loss
105    /// and before replacement, even if the handler itself remains the same Arc.
106    /// Consumers retaining authority must fail closed on `None`, non-live health,
107    /// or a changed generation, including while a previously valid reply waits
108    /// to execute. Recheck at dispatch, reply acceptance, and each frontend tick;
109    /// this snapshot cannot eliminate exit races after the final observation.
110    fn lifecycle_snapshot(&self) -> Option<ExtensionLifecycle> {
111        None
112    }
113
114    /// Handle a hook event. Returns the handler's decision.
115    async fn handle(&self, event: &HookEvent) -> HookResult;
116
117    /// Call an extension-provided tool.
118    async fn call_tool(&self, _name: &str, _input: Value) -> Result<Value, String> {
119        Err("extension runtime does not support tool.call".to_string())
120    }
121
122    /// Complete a chat request through an extension-provided model provider.
123    async fn provider_complete(
124        &self,
125        _params: ProviderCompleteParams,
126    ) -> Result<ProviderCompleteResult, String> {
127        Err("extension runtime does not support provider.complete".to_string())
128    }
129
130    /// Stream a chat request through an extension-provided model provider.
131    ///
132    /// The handler must forward `provider.stream.event` notifications to `sink`
133    /// in order. The returned `ProviderCompleteResult` is the final aggregated
134    /// response (so callers that don't need streaming can use it as the final
135    /// state). Implementations that don't support streaming should return
136    /// `Err("provider.stream is not supported by this extension")`.
137    async fn provider_stream(
138        &self,
139        _params: ProviderCompleteParams,
140        _sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
141    ) -> Result<ProviderCompleteResult, String> {
142        Err("provider.stream is not supported by this extension".to_string())
143    }
144
145    /// Invoke a plugin-registered interactive slash command. The handler must
146    /// forward `command.output` notifications matching `request_id` and any
147    /// `task.*` notifications to `sink`. Returns the final response value.
148    ///
149    /// CP-11 fix-3: the sink is BOUNDED and metered
150    /// ([`crate::extensions::invoke_output::InvokeEventSink`]). It must be
151    /// paired with an EAGERLY CONCURRENT consumer — normally the collector
152    /// half of [`crate::extensions::invoke_output::invoke_event_channel`],
153    /// joined with this call by
154    /// `ExtensionManager::invoke_command_collected` — which enforces
155    /// invocation-local byte/event budgets at production time. A hostile
156    /// `command.output` flood is therefore paced by awaited sends and
157    /// bounded retention instead of parking aggregate bytes in an
158    /// unbounded post-hoc queue (the pre-fix behavior). Handlers must
159    /// treat a closed sink like the old closed-unbounded-sink case: stop
160    /// forwarding, still complete the call.
161    async fn invoke_command(
162        &self,
163        _command: &str,
164        _args: Vec<String>,
165        _request_id: &str,
166        _sink: crate::extensions::invoke_output::InvokeEventSink,
167    ) -> Result<Value, String> {
168        Err("extension runtime does not support command.invoke".to_string())
169    }
170
171    /// Fetch optional plugin capability/build/model information.
172    async fn get_info(&self) -> Result<PluginInfo, String> {
173        Err("extension runtime does not support info.get".to_string())
174    }
175
176    /// Ask the plugin to supply sidecar spawn arguments. Used by the
177    /// modality-neutral sidecar bootstrap path (see
178    /// `crate::sidecar::spawn`); plugins that don't host a sidecar
179    /// should leave the default `Err` in place. Core treats the
180    /// returned [`SidecarSpawnArgs::args`] as opaque.
181    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
182        Err("extension runtime does not support sidecar.spawn_args".to_string())
183    }
184
185    /// Open a plugin-owned custom settings editor and return its initial render payload.
186    async fn settings_editor_open(&self, _category: &str, _field: &str) -> Result<Value, String> {
187        Err("extension runtime does not support settings.editor.open".to_string())
188    }
189
190    /// Forward a keypress to the active plugin-owned custom settings editor.
191    async fn settings_editor_key(
192        &self,
193        _category: &str,
194        _field: &str,
195        _key: &str,
196    ) -> Result<Value, String> {
197        Err("extension runtime does not support settings.editor.key".to_string())
198    }
199
200    /// Ask the plugin to commit a custom editor value selected by the UI.
201    async fn settings_editor_commit(
202        &self,
203        _category: &str,
204        _field: &str,
205        _value: Value,
206    ) -> Result<Value, String> {
207        Err("extension runtime does not support settings.editor.commit".to_string())
208    }
209
210    /// Gracefully shut down the extension.
211    async fn shutdown(&self);
212
213    /// Subscribe to raw JSON-RPC notifications from this extension.
214    /// Returns a monotonic subscription id (for unsubscribing) and a
215    /// receiver that yields every notification frame. The default
216    /// implementation returns a channel whose sender is immediately
217    /// dropped, so the receiver yields `None` right away — effectively
218    /// a no-op for handler impls that don't support notifications.
219    async fn subscribe_notifications(
220        &self,
221    ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
222        let (_tx, rx) = tokio::sync::mpsc::channel(1);
223        (0, rx)
224    }
225
226    /// Current health state of this handler.
227    async fn health(&self) -> ExtensionHealth {
228        ExtensionHealth::Running
229    }
230
231    /// Number of transport restarts observed by this handler.
232    async fn restart_count(&self) -> usize {
233        0
234    }
235}
236
237#[cfg(test)]
238mod lifecycle_tests {
239    use super::*;
240
241    struct UnsupportedHandler;
242
243    #[async_trait]
244    impl ExtensionHandler for UnsupportedHandler {
245        fn id(&self) -> &str {
246            "unsupported-lifecycle"
247        }
248
249        async fn handle(&self, _event: &HookEvent) -> HookResult {
250            unreachable!("snapshot must not dispatch a hook")
251        }
252
253        async fn shutdown(&self) {}
254    }
255
256    #[test]
257    fn default_lifecycle_snapshot_is_unavailable_through_trait_object() {
258        let handler: &dyn ExtensionHandler = &UnsupportedHandler;
259        assert_eq!(handler.lifecycle_snapshot(), None);
260    }
261}
262
263#[cfg(test)]
264pub mod tests {
265    use super::*;
266
267    /// Minimal handler for unit tests. Reports generation 0, Running health.
268    pub struct DummyHandler;
269
270    #[async_trait]
271    impl ExtensionHandler for DummyHandler {
272        fn id(&self) -> &str {
273            "dummy"
274        }
275
276        fn lifecycle_snapshot(&self) -> Option<ExtensionLifecycle> {
277            Some(ExtensionLifecycle {
278                health: ExtensionHealth::Running,
279                generation: 0,
280            })
281        }
282
283        async fn handle(&self, _event: &HookEvent) -> HookResult {
284            HookResult::default()
285        }
286
287        async fn shutdown(&self) {}
288    }
289}