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