Skip to main content

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}