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    /// Call an extension-provided tool on behalf of a conversation.
123    ///
124    /// `session_id` is the owning conversation id (`None` = worker/subagent
125    /// or no conversation). Runtimes that put it on the wire override this;
126    /// the default drops it and calls [`Self::call_tool`], so handlers that
127    /// predate the field (and wrappers that fail closed) keep their exact
128    /// behaviour.
129    async fn call_tool_for(
130        &self,
131        name: &str,
132        input: Value,
133        session_id: Option<&str>,
134    ) -> Result<Value, String> {
135        let _ = session_id;
136        self.call_tool(name, input).await
137    }
138
139    /// Complete a chat request through an extension-provided model provider.
140    async fn provider_complete(
141        &self,
142        _params: ProviderCompleteParams,
143    ) -> Result<ProviderCompleteResult, String> {
144        Err("extension runtime does not support provider.complete".to_string())
145    }
146
147    /// Stream a chat request through an extension-provided model provider.
148    ///
149    /// The handler must forward `provider.stream.event` notifications to `sink`
150    /// in order. The returned `ProviderCompleteResult` is the final aggregated
151    /// response (so callers that don't need streaming can use it as the final
152    /// state). Implementations that don't support streaming should return
153    /// `Err("provider.stream is not supported by this extension")`.
154    async fn provider_stream(
155        &self,
156        _params: ProviderCompleteParams,
157        _sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
158    ) -> Result<ProviderCompleteResult, String> {
159        Err("provider.stream is not supported by this extension".to_string())
160    }
161
162    /// Invoke a plugin-registered interactive slash command. The handler must
163    /// forward `command.output` notifications matching `request_id` and any
164    /// `task.*` notifications to `sink`. Returns the final response value.
165    ///
166    /// CP-11 fix-3: the sink is BOUNDED and metered
167    /// ([`crate::extensions::invoke_output::InvokeEventSink`]). It must be
168    /// paired with an EAGERLY CONCURRENT consumer — normally the collector
169    /// half of [`crate::extensions::invoke_output::invoke_event_channel`],
170    /// joined with this call by
171    /// `ExtensionManager::invoke_command_collected` — which enforces
172    /// invocation-local byte/event budgets at production time. A hostile
173    /// `command.output` flood is therefore paced by awaited sends and
174    /// bounded retention instead of parking aggregate bytes in an
175    /// unbounded post-hoc queue (the pre-fix behavior). Handlers must
176    /// treat a closed sink like the old closed-unbounded-sink case: stop
177    /// forwarding, still complete the call.
178    async fn invoke_command(
179        &self,
180        _command: &str,
181        _args: Vec<String>,
182        _request_id: &str,
183        _sink: crate::extensions::invoke_output::InvokeEventSink,
184    ) -> Result<Value, String> {
185        Err("extension runtime does not support command.invoke".to_string())
186    }
187
188    /// Fetch optional plugin capability/build/model information.
189    async fn get_info(&self) -> Result<PluginInfo, String> {
190        Err("extension runtime does not support info.get".to_string())
191    }
192
193    /// Ask the plugin to supply sidecar spawn arguments. Used by the
194    /// modality-neutral sidecar bootstrap path (see
195    /// `crate::sidecar::spawn`); plugins that don't host a sidecar
196    /// should leave the default `Err` in place. Core treats the
197    /// returned [`SidecarSpawnArgs::args`] as opaque.
198    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
199        Err("extension runtime does not support sidecar.spawn_args".to_string())
200    }
201
202    /// Open a plugin-owned custom settings editor and return its initial render payload.
203    async fn settings_editor_open(&self, _category: &str, _field: &str) -> Result<Value, String> {
204        Err("extension runtime does not support settings.editor.open".to_string())
205    }
206
207    /// Forward a keypress to the active plugin-owned custom settings editor.
208    async fn settings_editor_key(
209        &self,
210        _category: &str,
211        _field: &str,
212        _key: &str,
213    ) -> Result<Value, String> {
214        Err("extension runtime does not support settings.editor.key".to_string())
215    }
216
217    /// Ask the plugin to commit a custom editor value selected by the UI.
218    async fn settings_editor_commit(
219        &self,
220        _category: &str,
221        _field: &str,
222        _value: Value,
223    ) -> Result<Value, String> {
224        Err("extension runtime does not support settings.editor.commit".to_string())
225    }
226
227    /// Gracefully shut down the extension.
228    async fn shutdown(&self);
229
230    /// Subscribe to raw JSON-RPC notifications from this extension.
231    /// Returns a monotonic subscription id (for unsubscribing) and a
232    /// receiver that yields every notification frame. The default
233    /// implementation returns a channel whose sender is immediately
234    /// dropped, so the receiver yields `None` right away — effectively
235    /// a no-op for handler impls that don't support notifications.
236    async fn subscribe_notifications(
237        &self,
238    ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
239        let (_tx, rx) = tokio::sync::mpsc::channel(1);
240        (0, rx)
241    }
242
243    /// Current health state of this handler.
244    async fn health(&self) -> ExtensionHealth {
245        ExtensionHealth::Running
246    }
247
248    /// Number of transport restarts observed by this handler.
249    async fn restart_count(&self) -> usize {
250        0
251    }
252}
253
254#[cfg(test)]
255mod lifecycle_tests {
256    use super::*;
257
258    struct UnsupportedHandler;
259
260    #[async_trait]
261    impl ExtensionHandler for UnsupportedHandler {
262        fn id(&self) -> &str {
263            "unsupported-lifecycle"
264        }
265
266        async fn handle(&self, _event: &HookEvent) -> HookResult {
267            unreachable!("snapshot must not dispatch a hook")
268        }
269
270        async fn shutdown(&self) {}
271    }
272
273    #[test]
274    fn default_lifecycle_snapshot_is_unavailable_through_trait_object() {
275        let handler: &dyn ExtensionHandler = &UnsupportedHandler;
276        assert_eq!(handler.lifecycle_snapshot(), None);
277    }
278}
279
280#[cfg(test)]
281pub mod tests {
282    use super::*;
283
284    /// Minimal handler for unit tests. Reports generation 0, Running health.
285    pub struct DummyHandler;
286
287    #[async_trait]
288    impl ExtensionHandler for DummyHandler {
289        fn id(&self) -> &str {
290            "dummy"
291        }
292
293        fn lifecycle_snapshot(&self) -> Option<ExtensionLifecycle> {
294            Some(ExtensionLifecycle {
295                health: ExtensionHealth::Running,
296                generation: 0,
297            })
298        }
299
300        async fn handle(&self, _event: &HookEvent) -> HookResult {
301            HookResult::default()
302        }
303
304        async fn shutdown(&self) {}
305    }
306}