agent_engine/extensions/loader.rs
1//! Async extension loading orchestration.
2//!
3//! The chat UI owns the manager behind an async lock; this module keeps startup
4//! snappy by running discovery/loading in the background and streaming progress
5//! events back to the UI (which can render them as toasts).
6
7use std::path::Path;
8use std::sync::Arc;
9
10use tokio::sync::{mpsc, RwLock};
11
12use super::hooks::events::{HookEvent, HookResult};
13use super::manager::{ExtensionLoadFailure, ExtensionManager};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ExtensionLoaderEvent {
17 Started,
18 Loaded {
19 plugin: String,
20 loaded: usize,
21 failed: usize,
22 },
23 Failed {
24 failure: ExtensionLoadFailure,
25 loaded: usize,
26 failed: usize,
27 },
28 Finished {
29 loaded: Vec<String>,
30 failed: Vec<ExtensionLoadFailure>,
31 },
32}
33
34impl ExtensionLoaderEvent {
35 pub fn progress_counts(&self) -> Option<(usize, usize)> {
36 match self {
37 ExtensionLoaderEvent::Started => Some((0, 0)),
38 ExtensionLoaderEvent::Loaded { loaded, failed, .. } => Some((*loaded, *failed)),
39 ExtensionLoaderEvent::Failed { loaded, failed, .. } => Some((*loaded, *failed)),
40 ExtensionLoaderEvent::Finished { loaded, failed } => Some((loaded.len(), failed.len())),
41 }
42 }
43}
44
45/// Session-level: fire `on_session_start` for one session and store any
46/// `inject` as that session's keyed injection (`stream.rs` reads it when
47/// composing the system prompt). Call AFTER discovery is known-finished —
48/// in-process hosts get that from [`spawn_discover_and_load`] itself; the
49/// daemon's `SessionActor` awaits `EngineHost::extensions_ready()` first.
50/// Never re-runs discovery. Returns `true` when something was injected.
51/// `cwd` is the session's folder (`None` = the process folder).
52pub async fn emit_session_start(
53 hook_bus: &Arc<super::hooks::HookBus>,
54 session_id: &str,
55 cwd: Option<&Path>,
56) -> bool {
57 let event = HookEvent::on_session_start(session_id).with_cwd(cwd);
58 if let HookResult::Inject { content } = hook_bus.emit(&event).await {
59 tracing::debug!(
60 len = content.len(),
61 "on_session_start injected session-scoped context"
62 );
63 hook_bus
64 .set_session_injection_for(session_id, content)
65 .await;
66 return true;
67 }
68 false
69}
70
71/// Session-level: fire `on_session_end` for one session — concurrent
72/// dispatch (the hook only allows `continue`, so ordering cannot matter),
73/// fail-open under `budget`. Clears the session's keyed injection
74/// afterwards. Returns `false` if the budget elapsed (handlers may not have
75/// flushed; the transcript is already saved by the caller, so nothing is
76/// lost). Per session, not per process: every session in a daemon gets its
77/// own end event. `cwd` as for [`emit_session_start`].
78pub async fn emit_session_end(
79 hook_bus: &Arc<super::hooks::HookBus>,
80 session_id: &str,
81 cwd: Option<&Path>,
82 transcript: Option<Vec<crate::SharedMessage>>,
83 budget: std::time::Duration,
84) -> bool {
85 let event = HookEvent::on_session_end(session_id, transcript).with_cwd(cwd);
86 let completed = match tokio::time::timeout(budget, hook_bus.emit_concurrent(&event)).await {
87 Ok(_) => {
88 tracing::debug!(session = %session_id, "on_session_end hooks completed");
89 true
90 }
91 Err(_elapsed) => {
92 tracing::warn!(
93 session = %session_id,
94 budget_secs = budget.as_secs(),
95 "on_session_end hooks timed out — extensions may not have flushed"
96 );
97 false
98 }
99 };
100 hook_bus.clear_session_injection(session_id).await;
101 completed
102}
103
104/// Discover and load extensions in the background, then fire `on_session_start`.
105///
106/// Process-level and IDEMPOTENT (daemon-mode C2): the manager records its
107/// discovery result, so a second call on the same manager (second host in
108/// one process, or a second session in a daemon) replays `Finished` from
109/// that record and spawns nothing. `on_session_start` still fires for
110/// `session_id` on every call — that part is per session.
111///
112/// The hook is emitted HERE, not at engine boot, and that placement is the
113/// whole point. `engine::setup::boot()` used to emit it while constructing an
114/// empty `ExtensionManager` — before any host had called this function — so
115/// the event reached zero subscribers in every host (TUI, server, rpc). No
116/// extension had ever received `on_session_start`. A comment in
117/// `cmd/server.rs` recorded the symptom ("no subscribers until extensions
118/// loaded") without it being recognised as a defect.
119///
120/// `session_id` is the session the hook reports. `None` skips the emit, for
121/// callers that have no session (or extensions disabled).
122pub fn spawn_discover_and_load(
123 manager: Arc<RwLock<ExtensionManager>>,
124 tx: mpsc::UnboundedSender<ExtensionLoaderEvent>,
125 session_id: Option<String>,
126) -> tokio::task::JoinHandle<()> {
127 // Host seam (C2): when this is the process host's manager, tell the host
128 // a walk is in flight BEFORE the task runs, and flip `extensions_ready`
129 // at Finished so `SessionActor::create` fires `on_session_start` only
130 // once extensions are subscribed.
131 // The guard marks ready on ANY task exit (incl. panic) so
132 // `extensions_ready()` waiters are never stranded.
133 let ready_guard = crate::host::EngineHost::current()
134 .filter(|host| Arc::ptr_eq(host.ext_manager(), &manager))
135 .map(|host| host.extensions_loading_guard());
136 tokio::spawn(async move {
137 let _ = tx.send(ExtensionLoaderEvent::Started);
138 let (loaded, failed) = manager
139 .write()
140 .await
141 .discover_and_load_with_progress(|event| {
142 let _ = tx.send(event);
143 })
144 .await;
145
146 // Extensions are now subscribed; the event has somewhere to land.
147 if let Some(session_id) = session_id {
148 let hook_bus = manager.read().await.hook_bus().clone();
149 // In-process hosts: the session runs in the process folder.
150 emit_session_start(&hook_bus, &session_id, None).await;
151 }
152
153 // Ready BEFORE Finished, as before.
154 drop(ready_guard);
155 let _ = tx.send(ExtensionLoaderEvent::Finished { loaded, failed });
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn progress_counts_report_finished_totals() {
165 let event = ExtensionLoaderEvent::Finished {
166 loaded: vec!["a".into(), "b".into()],
167 failed: vec![ExtensionLoadFailure {
168 plugin: "bad".into(),
169 manifest_path: None,
170 reason: "oops".into(),
171 hint: "fix it".into(),
172 }],
173 };
174 assert_eq!(event.progress_counts(), Some((2, 1)));
175 }
176}