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}