Skip to main content

agent_engine/engine/
setup.rs

1//! Engine setup — boot sequence shared by TUI and headless modes.
2//!
3//! Extracts the initialization logic that was previously inlined in
4//! chatui/mod.rs so both renderers can use the same boot path.
5
6use crate::skills::keybinds::KeybindRegistry;
7use crate::skills::registry::CommandRegistry;
8use crate::{latest_session, resolve_session, Result, Runtime, Session};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12/// Options for engine boot.
13pub struct EngineOpts {
14    pub continue_session: Option<Option<String>>,
15    pub system: Option<String>,
16    pub prompt_manifest: Option<std::path::PathBuf>,
17    pub profile: Option<String>,
18    pub no_extensions: bool,
19}
20
21/// Background tasks spawned during boot. Aborts on drop.
22pub struct BackgroundTasks {
23    watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
24    watcher_task: tokio::task::JoinHandle<()>,
25    socket_shutdown: Arc<std::sync::atomic::AtomicBool>,
26    socket_task: tokio::task::JoinHandle<()>,
27    #[allow(dead_code)] // stored for potential future use (e.g. reconnect)
28    session_socket_path: String,
29    session_id: String,
30    /// File-appender flush guard. Holding this for the lifetime of the
31    /// renderer keeps the non-blocking log writer's background thread
32    /// alive — without it, log lines emitted after `boot()` returns can
33    /// be silently dropped before they reach disk. Dropped last when
34    /// BackgroundTasks drops.
35    #[allow(dead_code)]
36    log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
37}
38
39impl BackgroundTasks {
40    /// Signal all tasks to stop and unregister the session.
41    pub fn shutdown(&self) {
42        self.watcher_shutdown
43            .store(true, std::sync::atomic::Ordering::Release);
44        self.socket_shutdown
45            .store(true, std::sync::atomic::Ordering::Release);
46        crate::events::registry::unregister_session(&self.session_id);
47    }
48}
49
50impl Drop for BackgroundTasks {
51    fn drop(&mut self) {
52        self.watcher_shutdown
53            .store(true, std::sync::atomic::Ordering::Relaxed);
54        self.socket_shutdown
55            .store(true, std::sync::atomic::Ordering::Relaxed);
56        self.watcher_task.abort();
57        self.socket_task.abort();
58    }
59}
60
61/// Result of the boot sequence — everything a renderer needs to start.
62pub struct EngineBoot {
63    pub runtime: Runtime,
64    pub config: crate::SynapsConfig,
65    /// Echo of EngineOpts.no_extensions — callers gate extension discovery
66    /// on this so the flag has one source of truth.
67    pub no_extensions: bool,
68    pub session: Session,
69    pub api_messages: Vec<crate::SharedMessage>,
70    pub total_input_tokens: u64,
71    pub total_output_tokens: u64,
72    pub session_cost: f64,
73    pub abort_context: Option<String>,
74    pub continued: bool,
75    pub continue_info: Option<ContinueInfo>,
76    pub registry: Arc<CommandRegistry>,
77    /// Keybind registry. Uses std::sync::RwLock (not tokio) because keybind
78    /// lookups are synchronous, fast, and called from input handling code
79    /// that cannot await. This is safe as long as the lock is never held
80    /// across an await point.
81    pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
82    pub mcp_server_count: usize,
83    pub system_prompt_path: std::path::PathBuf,
84    pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
85    /// Background tasks — inbox watcher, socket listener. Aborts on drop.
86    pub background: BackgroundTasks,
87}
88
89/// Info about how a continued session was resolved.
90pub struct ContinueInfo {
91    pub session_id: String,
92    pub resolved_via: Option<String>, // "chain", "name", or None
93    pub query: String,
94}
95
96/// Run the full engine boot sequence:
97/// config → system prompt → skills → MCP → session → sockets → extensions
98pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
99    if let Some(ref prof) = opts.profile {
100        crate::config::set_profile(Some(prof.clone()));
101    }
102
103    // Capture the WorkerGuard from the file appender. tracing-appender's
104    // non-blocking writer uses a background flush thread; the guard is
105    // an RAII handle that stops that thread on drop. The previous code
106    // dropped it at the end of boot() with a comment claiming "this is
107    // fine because tracing-subscriber uses a global subscriber" — which
108    // is true for the subscriber, but NOT for the file appender's
109    // background thread. With the guard dropped, log lines emitted after
110    // boot() returned (Extension loaded, hook traces, etc.) could be
111    // silently lost. We hand the guard down through EngineBoot so the
112    // renderer (TUI / chat / server) keeps it alive for its lifetime.
113    let log_guard = crate::logging::init_logging();
114    let mut runtime = Runtime::new().await?;
115
116    // Load config and apply
117    let config = crate::config::load_config();
118    runtime.apply_config(&config);
119
120    // Resolve the final foreground route before compiling immutable delegation
121    // policy. Continuing a session may replace the configured model.
122    let sb = resolve_or_create_session(&mut runtime, &opts.continue_session)?;
123
124    // Validate and compile an opted-in manifest before any session/network work.
125    let legacy_prompt = crate::config::resolve_system_prompt(opts.system.as_deref());
126    if let Some(path) = &opts.prompt_manifest {
127        let raw = std::fs::read_to_string(path)
128            .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
129        let manifest = agent_core::prompt::PromptManifest::parse(&raw)
130            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
131        let registry = manifest
132            .registry(path.parent())
133            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
134        let model = crate::orchestration::canonical_foreground_identity(runtime.model())
135            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
136        let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
137            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
138        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
139            &model,
140            manifest.delegation_catalog_candidates(),
141        )
142        .map_err(|error| crate::RuntimeError::Config(error.into()))?;
143        let delegation_policy = manifest
144            .delegation_policy(model.clone(), &catalog)
145            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
146        let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
147        if let Some(policy) = delegation_policy {
148            runtime.install_orchestration(Arc::new(
149                crate::orchestration::OrchestrationRuntime::new(policy),
150            ));
151        } else {
152            runtime.install_orchestration(Arc::new(
153                crate::orchestration::OrchestrationRuntime::baseline(model.clone(), 8, 64)
154                    .map_err(|error| crate::RuntimeError::Config(error.into()))?,
155            ));
156        }
157        let user = opts
158            .system
159            .as_ref()
160            .map(|_| {
161                agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
162            })
163            .transpose()
164            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
165        let stack =
166            agent_core::prompt::compile_prompt_stack(&manifest, &registry, &context, user.clone())
167                .map_err(|e| {
168                    crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
169                })?;
170        runtime
171            .apply_prompt_stack(stack)
172            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
173        runtime.retain_prompt_reload_source(path.clone(), context, user, delegation_policy_digest);
174    } else {
175        let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
176            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
177        runtime.install_orchestration(Arc::new(
178            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
179                .map_err(|error| crate::RuntimeError::Config(error.into()))?,
180        ));
181        runtime.set_system_prompt(legacy_prompt);
182    }
183
184    // Discover plugins/skills, build command registry, register load_skill tool.
185    let tools_shared = runtime.tools_shared();
186    let (registry, keybind_registry) = crate::skills::register(&tools_shared, &config).await;
187
188    // Set up MCP loading (if configured in ~/.synaps-cli/mcp.json). Flag-off
189    // keeps the legacy connect gateway; progressive disclosure switches to
190    // exact descriptor-backed dormant tools (Task 19) with no gateway.
191    let mcp_server_count =
192        crate::mcp::setup_lazy_mcp(&runtime.tools_shared(), config.progressive_tool_disclosure)
193            .await;
194    if config.progressive_tool_disclosure {
195        // Exact MCP mode: one shared lease manager for the runtime. Streams
196        // mint session capabilities/guards from it; children die with the
197        // session (RAII) or on runtime drop.
198        runtime.install_mcp_runtime(std::sync::Arc::new(crate::mcp::McpRuntimeManager::new(
199            crate::mcp::lease::config_source_from_disk(),
200            crate::mcp::lease::DEFAULT_IDLE_MAX,
201        )));
202    }
203
204    let system_prompt_path = crate::config::resolve_read_path("system.md");
205
206    // Session was resolved before policy compilation so its model is the immutable
207    // foreground identity used by worker inheritance and authorization.
208
209    // Start inbox watcher
210    let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
211    let watcher_task = {
212        let inbox_dir = crate::config::base_dir().join("inbox");
213        let event_queue = runtime.event_queue().clone();
214        let shutdown = watcher_shutdown.clone();
215        tokio::spawn(async move {
216            crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
217        })
218    };
219
220    // Helper: abort background tasks on error
221    let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
222                       wt: &tokio::task::JoinHandle<()>| {
223        ws.store(true, std::sync::atomic::Ordering::Relaxed);
224        wt.abort();
225    };
226
227    // Start per-session Unix socket listener + register in session registry
228    let socket_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
229    let session_socket_path = crate::events::registry::socket_path_for_session(&sb.session.id);
230    let socket_task = crate::events::socket::listen_session_socket(
231        session_socket_path.clone(),
232        runtime.event_queue().clone(),
233        socket_shutdown.clone(),
234    );
235    let session_registration = crate::events::registry::SessionRegistration {
236        session_id: sb.session.id.clone(),
237        name: sb.session.name.clone(),
238        socket_path: session_socket_path.clone(),
239        pid: std::process::id(),
240        started_at: chrono::Utc::now(),
241    };
242    if let Err(e) = crate::events::registry::register_session(&session_registration) {
243        abort_tasks(&watcher_shutdown, &watcher_task);
244        socket_shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
245        socket_task.abort();
246        // Fail loudly: returning Ok with already-aborted handles silently
247        // poisoned downstream — server inherited dead watcher/socket tasks
248        // and a session that wasn't in the registry, so other tools couldn't
249        // see it. Better to fail boot than start in a broken state.
250        return Err(crate::core::error::RuntimeError::Session(format!(
251            "failed to register session {}: {}",
252            session_registration.session_id, e
253        )));
254    }
255
256    // Task 23: the engine's interactive session runs under the FOREGROUND
257    // turn budget with typed per-role config overrides applied.
258    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
259        crate::runtime::budget::TurnRole::Foreground,
260        &config.turn_budgets,
261    ));
262
263    // Extension manager
264    let mut ext_mgr = crate::extensions::manager::ExtensionManager::new_with_tools(
265        Arc::clone(runtime.hook_bus()),
266        runtime.tools_shared(),
267    );
268    // Task 20: progressive disclosure defers tool-only extension spawns
269    // (dormant descriptors only); flag-off keeps the legacy eager loads.
270    ext_mgr.set_progressive_deferral(config.progressive_tool_disclosure);
271    if config.progressive_tool_disclosure {
272        // ONE shared extension runtime lease manager: the manager uses it
273        // for unload revocation; the runtime mints per-session capabilities
274        // and the durable session-end scope from the SAME instance.
275        runtime.install_extension_runtime(ext_mgr.extension_runtime());
276    }
277    let ext_manager = Arc::new(RwLock::new(ext_mgr));
278    crate::runtime::openai::set_extension_manager_for_routing(Arc::clone(&ext_manager));
279
280    // Session start index record.
281    //
282    // The `on_session_start` HOOK is deliberately NOT emitted here. Extensions
283    // are loaded by the host after boot returns (see
284    // `extensions::loader::spawn_discover_and_load`), so emitting at this
285    // point delivered the event to an empty bus in every host — the hook had
286    // never once reached an extension. It is now emitted by the loader, after
287    // subscribers exist.
288    {
289        let mut index_record =
290            crate::core::session_index::SessionIndexRecord::start(&sb.session.id);
291        index_record.model = Some(sb.session.model.clone());
292        index_record.profile = crate::core::config::get_profile();
293        index_record.cwd = std::env::current_dir().ok();
294        if let Err(err) = crate::core::session_index::append_record(&index_record) {
295            tracing::warn!("failed to append session start index record: {}", err);
296        }
297    }
298
299    if mcp_server_count > 0 {
300        tracing::info!(
301            "{} MCP servers available (use connect_mcp_server to activate)",
302            mcp_server_count
303        );
304    }
305
306    let session_id = sb.session.id.clone();
307
308    Ok(EngineBoot {
309        runtime,
310        config,
311        no_extensions: opts.no_extensions,
312        session: sb.session,
313        api_messages: sb.api_messages,
314        total_input_tokens: sb.total_input_tokens,
315        total_output_tokens: sb.total_output_tokens,
316        session_cost: sb.session_cost,
317        abort_context: sb.abort_context,
318        continued: sb.continued,
319        continue_info: sb.continue_info,
320        registry,
321        keybind_registry,
322        mcp_server_count,
323        system_prompt_path,
324        ext_manager,
325        background: BackgroundTasks {
326            watcher_shutdown,
327            watcher_task,
328            socket_shutdown,
329            socket_task,
330            session_socket_path,
331            session_id,
332            log_guard,
333        },
334    })
335}
336
337/// Resolve a session to continue, or create a new one.
338/// Result of session resolution.
339struct SessionBootResult {
340    session: Session,
341    api_messages: Vec<crate::SharedMessage>,
342    total_input_tokens: u64,
343    total_output_tokens: u64,
344    session_cost: f64,
345    abort_context: Option<String>,
346    continued: bool,
347    continue_info: Option<ContinueInfo>,
348}
349
350fn resolve_or_create_session(
351    runtime: &mut Runtime,
352    continue_session: &Option<Option<String>>,
353) -> Result<SessionBootResult> {
354    match continue_session {
355        Some(ref maybe_id) => {
356            let session = match maybe_id {
357                Some(ref id) => resolve_session(id).map_err(|e| {
358                    crate::error::RuntimeError::Tool(format!(
359                        "Failed to load session '{}': {}",
360                        id, e
361                    ))
362                })?,
363                None => latest_session().map_err(|e| {
364                    crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
365                })?,
366            };
367            runtime.set_model(session.model.clone());
368            // Restore the session's named reasoning level so max/ultra/off
369            // and custom budgets survive --continue.
370            if let Some(level) =
371                agent_core::reasoning::ReasoningLevel::parse(&session.thinking_level)
372            {
373                runtime.set_reasoning_level_explicit(level);
374            } else if let Some(budget) =
375                crate::models::budget_for_thinking_level(&session.thinking_level)
376            {
377                runtime.set_thinking_budget_explicit(budget);
378            }
379            if let Some(ref sp) = session.system_prompt {
380                runtime.set_system_prompt(sp.clone());
381            }
382
383            let continue_info = maybe_id.as_ref().map(|q| {
384                let resolved_via = if *q != session.id {
385                    if crate::chain::load_chain(q).is_ok() {
386                        Some("chain".to_string())
387                    } else if crate::session::find_session_by_name(q).is_ok() {
388                        Some("name".to_string())
389                    } else {
390                        None
391                    }
392                } else {
393                    None
394                };
395                ContinueInfo {
396                    session_id: session.id.clone(),
397                    resolved_via,
398                    query: q.clone(),
399                }
400            });
401
402            Ok(SessionBootResult {
403                api_messages: session.api_messages.clone(),
404                total_input_tokens: session.total_input_tokens,
405                total_output_tokens: session.total_output_tokens,
406                session_cost: session.session_cost,
407                abort_context: session.abort_context.clone(),
408                continued: true,
409                continue_info,
410                session,
411            })
412        }
413        None => {
414            let session = Session::new(
415                runtime.model(),
416                runtime.thinking_level(),
417                runtime.system_prompt(),
418            );
419            Ok(SessionBootResult {
420                session,
421                api_messages: Vec::new(),
422                total_input_tokens: 0,
423                total_output_tokens: 0,
424                session_cost: 0.0,
425                abort_context: None,
426                continued: false,
427                continue_info: None,
428            })
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use agent_core::reasoning::ReasoningLevel;
437
438    /// B1: --continue path must restore thinking_level from the saved session.
439    /// Simulates what resolve_or_create_session does when a session is continued.
440    #[test]
441    fn continue_path_restores_thinking_level_from_session() {
442        let mut runtime = Runtime::new_headless();
443
444        // Simulate what resolve_or_create_session does on --continue.
445        let thinking_level_str = "ultra";
446        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
447            runtime.set_reasoning_level_explicit(level);
448        }
449
450        assert_eq!(
451            runtime.reasoning_level(),
452            ReasoningLevel::Ultra,
453            "thinking level must be restored from session on --continue"
454        );
455        assert!(
456            runtime.is_reasoning_explicit(),
457            "restored thinking level must be marked explicit so set_model won't overwrite it"
458        );
459    }
460
461    #[test]
462    fn continue_path_restores_max_level() {
463        let mut runtime = Runtime::new_headless();
464        let thinking_level_str = "max";
465        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
466            runtime.set_reasoning_level_explicit(level);
467        }
468        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
469    }
470
471    #[test]
472    fn continue_path_restores_off_level() {
473        let mut runtime = Runtime::new_headless();
474        let thinking_level_str = "off";
475        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
476            runtime.set_reasoning_level_explicit(level);
477        }
478        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
479    }
480}