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 /// [`Self::handle`] for a caller that must never start or restart the
118 /// extension: a subagent worker's hook view
119 /// ([`HookBus::worker_transforms`]). A worker runs on its own throwaway
120 /// current-thread tokio runtime, so a child process or reader task
121 /// spawned from it would die with the worker and leave the shared
122 /// extension broken for the foreground and every other worker. An
123 /// extension that is not running (crashed, stopped by a reload, deferred
124 /// and not started yet) answers `Continue`; the foreground's next call
125 /// starts it as before. Default: [`Self::handle`] (nothing to start).
126 ///
127 /// [`HookBus::worker_transforms`]: crate::extensions::hooks::HookBus::worker_transforms
128 async fn handle_no_spawn(&self, event: &HookEvent) -> HookResult {
129 self.handle(event).await
130 }
131
132 /// Call an extension-provided tool on behalf of a conversation. The
133 /// only way to call a tool: every caller says who the call is for.
134 ///
135 /// `session_id` is the owning conversation id (`None` = worker/subagent
136 /// or no conversation); `cwd` is the folder the caller runs in (`None` =
137 /// the process folder, which under the daemon is the daemon's, so a
138 /// call made for a chat passes the chat's). Default: this runtime
139 /// serves no tools.
140 async fn call_tool_for(
141 &self,
142 name: &str,
143 input: Value,
144 session_id: Option<&str>,
145 cwd: Option<&std::path::Path>,
146 ) -> Result<Value, String> {
147 let _ = (name, input, session_id, cwd);
148 Err("extension runtime does not support tool.call".to_string())
149 }
150
151 /// [`Self::call_tool_for`], naming the run that calls: `caller` is the
152 /// author of the calling run's memory binding (`None` = the call's
153 /// context has no binding), sent as `tool.call` `params.caller` so an
154 /// extension can tell a chat from each of its workers. What a registered
155 /// extension tool calls. Default: [`Self::call_tool_for`], for a handler
156 /// that has no wire to put it on.
157 async fn call_tool_as(
158 &self,
159 name: &str,
160 input: Value,
161 session_id: Option<&str>,
162 cwd: Option<&std::path::Path>,
163 caller: Option<&agent_core::memory::author::Author>,
164 ) -> Result<Value, String> {
165 let _ = caller;
166 self.call_tool_for(name, input, session_id, cwd).await
167 }
168
169 /// [`Self::call_tool_for`] for a call that belongs to no chat: on the
170 /// wire `session_id` is `null` and `cwd` is the process folder.
171 ///
172 /// Tests only (the `testing` feature). Production has no such call:
173 /// each one is made for a chat or a worker and says which folder. Should
174 /// a genuine one appear, un-gate this and call it by name instead of
175 /// passing `None, None`, so every no-chat call can be found by grep.
176 #[cfg(any(test, feature = "testing"))]
177 async fn call_tool_no_chat(&self, name: &str, input: Value) -> Result<Value, String> {
178 self.call_tool_for(name, input, None, None).await
179 }
180
181 /// Complete a chat request through an extension-provided model provider.
182 async fn provider_complete(
183 &self,
184 _params: ProviderCompleteParams,
185 ) -> Result<ProviderCompleteResult, String> {
186 Err("extension runtime does not support provider.complete".to_string())
187 }
188
189 /// Stream a chat request through an extension-provided model provider.
190 ///
191 /// The handler must forward `provider.stream.event` notifications to `sink`
192 /// in order. The returned `ProviderCompleteResult` is the final aggregated
193 /// response (so callers that don't need streaming can use it as the final
194 /// state). Implementations that don't support streaming should return
195 /// `Err("provider.stream is not supported by this extension")`.
196 async fn provider_stream(
197 &self,
198 _params: ProviderCompleteParams,
199 _sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
200 ) -> Result<ProviderCompleteResult, String> {
201 Err("provider.stream is not supported by this extension".to_string())
202 }
203
204 /// Invoke a plugin-registered interactive slash command. The handler must
205 /// forward `command.output` notifications matching `request_id` and any
206 /// `task.*` notifications to `sink`. Returns the final response value.
207 ///
208 /// CP-11 fix-3: the sink is BOUNDED and metered
209 /// ([`crate::extensions::invoke_output::InvokeEventSink`]). It must be
210 /// paired with an EAGERLY CONCURRENT consumer — normally the collector
211 /// half of [`crate::extensions::invoke_output::invoke_event_channel`],
212 /// joined with this call by
213 /// `ExtensionManager::invoke_command_collected` — which enforces
214 /// invocation-local byte/event budgets at production time. A hostile
215 /// `command.output` flood is therefore paced by awaited sends and
216 /// bounded retention instead of parking aggregate bytes in an
217 /// unbounded post-hoc queue (the pre-fix behavior). Handlers must
218 /// treat a closed sink like the old closed-unbounded-sink case: stop
219 /// forwarding, still complete the call.
220 async fn invoke_command(
221 &self,
222 _command: &str,
223 _args: Vec<String>,
224 _request_id: &str,
225 _sink: crate::extensions::invoke_output::InvokeEventSink,
226 ) -> Result<Value, String> {
227 Err("extension runtime does not support command.invoke".to_string())
228 }
229
230 /// Fetch optional plugin capability/build/model information.
231 async fn get_info(&self) -> Result<PluginInfo, String> {
232 Err("extension runtime does not support info.get".to_string())
233 }
234
235 /// Open a plugin-owned custom settings editor and return its initial render payload.
236 async fn settings_editor_open(&self, _category: &str, _field: &str) -> Result<Value, String> {
237 Err("extension runtime does not support settings.editor.open".to_string())
238 }
239
240 /// Forward a keypress to the active plugin-owned custom settings editor.
241 async fn settings_editor_key(
242 &self,
243 _category: &str,
244 _field: &str,
245 _key: &str,
246 ) -> Result<Value, String> {
247 Err("extension runtime does not support settings.editor.key".to_string())
248 }
249
250 /// Ask the plugin to commit a custom editor value selected by the UI.
251 async fn settings_editor_commit(
252 &self,
253 _category: &str,
254 _field: &str,
255 _value: Value,
256 ) -> Result<Value, String> {
257 Err("extension runtime does not support settings.editor.commit".to_string())
258 }
259
260 /// Gracefully shut down the extension.
261 async fn shutdown(&self);
262
263 /// Subscribe to raw JSON-RPC notifications from this extension.
264 /// Returns a monotonic subscription id (for unsubscribing) and a
265 /// receiver that yields every notification frame. The default
266 /// implementation returns a channel whose sender is immediately
267 /// dropped, so the receiver yields `None` right away — effectively
268 /// a no-op for handler impls that don't support notifications.
269 async fn subscribe_notifications(
270 &self,
271 ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
272 let (_tx, rx) = tokio::sync::mpsc::channel(1);
273 (0, rx)
274 }
275
276 /// Current health state of this handler.
277 async fn health(&self) -> ExtensionHealth {
278 ExtensionHealth::Running
279 }
280
281 /// Number of transport restarts observed by this handler.
282 async fn restart_count(&self) -> usize {
283 0
284 }
285}
286
287#[cfg(test)]
288mod lifecycle_tests {
289 use super::*;
290
291 struct UnsupportedHandler;
292
293 #[async_trait]
294 impl ExtensionHandler for UnsupportedHandler {
295 fn id(&self) -> &str {
296 "unsupported-lifecycle"
297 }
298
299 async fn handle(&self, _event: &HookEvent) -> HookResult {
300 unreachable!("snapshot must not dispatch a hook")
301 }
302
303 async fn shutdown(&self) {}
304 }
305
306 #[test]
307 fn default_lifecycle_snapshot_is_unavailable_through_trait_object() {
308 let handler: &dyn ExtensionHandler = &UnsupportedHandler;
309 assert_eq!(handler.lifecycle_snapshot(), None);
310 }
311
312 /// A runtime that implements no tool call serves none, whoever the call
313 /// is for.
314 #[tokio::test]
315 async fn default_call_tool_for_serves_no_tool() {
316 let handler: &dyn ExtensionHandler = &UnsupportedHandler;
317 let chat = std::env::temp_dir().join("chat-folder");
318 for (session_id, cwd) in [(Some("chat-A"), Some(chat.as_path())), (None, None)] {
319 let err = handler
320 .call_tool_for("any", serde_json::json!({}), session_id, cwd)
321 .await
322 .unwrap_err();
323 assert!(err.contains("does not support tool.call"), "{err}");
324 }
325 }
326
327 /// Who each tool call said it was for.
328 #[derive(Default)]
329 struct Callers(std::sync::Mutex<Vec<(Option<String>, Option<std::path::PathBuf>)>>);
330
331 #[async_trait]
332 impl ExtensionHandler for Callers {
333 fn id(&self) -> &str {
334 "callers"
335 }
336 async fn handle(&self, _event: &HookEvent) -> HookResult {
337 HookResult::default()
338 }
339 async fn shutdown(&self) {}
340 async fn call_tool_for(
341 &self,
342 _name: &str,
343 _input: Value,
344 session_id: Option<&str>,
345 cwd: Option<&std::path::Path>,
346 ) -> Result<Value, String> {
347 self.0.lock().unwrap().push((
348 session_id.map(str::to_string),
349 cwd.map(std::path::Path::to_path_buf),
350 ));
351 Ok(Value::Null)
352 }
353 }
354
355 /// The one named way to call for no chat sends no conversation and no
356 /// folder; every other call carries what its caller passed.
357 #[tokio::test]
358 async fn only_the_named_no_chat_call_omits_the_caller() {
359 let handler = Callers::default();
360 let chat = std::env::temp_dir().join("chat-folder");
361 handler
362 .call_tool_for("t", serde_json::json!({}), Some("chat-A"), Some(&chat))
363 .await
364 .unwrap();
365 handler
366 .call_tool_no_chat("t", serde_json::json!({}))
367 .await
368 .unwrap();
369 assert_eq!(
370 *handler.0.lock().unwrap(),
371 vec![(Some("chat-A".to_string()), Some(chat)), (None, None)]
372 );
373 }
374}
375
376#[cfg(test)]
377pub mod tests {
378 use super::*;
379
380 /// Minimal handler for unit tests. Reports generation 0, Running health.
381 pub struct DummyHandler;
382
383 #[async_trait]
384 impl ExtensionHandler for DummyHandler {
385 fn id(&self) -> &str {
386 "dummy"
387 }
388
389 fn lifecycle_snapshot(&self) -> Option<ExtensionLifecycle> {
390 Some(ExtensionLifecycle {
391 health: ExtensionHealth::Running,
392 generation: 0,
393 })
394 }
395
396 async fn handle(&self, _event: &HookEvent) -> HookResult {
397 HookResult::default()
398 }
399
400 async fn shutdown(&self) {}
401 }
402}