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}