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 hook
281    {
282        let mut index_record =
283            crate::core::session_index::SessionIndexRecord::start(&sb.session.id);
284        index_record.model = Some(sb.session.model.clone());
285        index_record.profile = crate::core::config::get_profile();
286        index_record.cwd = std::env::current_dir().ok();
287        if let Err(err) = crate::core::session_index::append_record(&index_record) {
288            tracing::warn!("failed to append session start index record: {}", err);
289        }
290
291        let hook_event =
292            crate::extensions::hooks::events::HookEvent::on_session_start(&sb.session.id);
293        let _ = runtime.hook_bus().emit(&hook_event).await;
294    }
295
296    if mcp_server_count > 0 {
297        tracing::info!(
298            "{} MCP servers available (use connect_mcp_server to activate)",
299            mcp_server_count
300        );
301    }
302
303    let session_id = sb.session.id.clone();
304
305    Ok(EngineBoot {
306        runtime,
307        config,
308        no_extensions: opts.no_extensions,
309        session: sb.session,
310        api_messages: sb.api_messages,
311        total_input_tokens: sb.total_input_tokens,
312        total_output_tokens: sb.total_output_tokens,
313        session_cost: sb.session_cost,
314        abort_context: sb.abort_context,
315        continued: sb.continued,
316        continue_info: sb.continue_info,
317        registry,
318        keybind_registry,
319        mcp_server_count,
320        system_prompt_path,
321        ext_manager,
322        background: BackgroundTasks {
323            watcher_shutdown,
324            watcher_task,
325            socket_shutdown,
326            socket_task,
327            session_socket_path,
328            session_id,
329            log_guard,
330        },
331    })
332}
333
334/// Resolve a session to continue, or create a new one.
335/// Result of session resolution.
336struct SessionBootResult {
337    session: Session,
338    api_messages: Vec<crate::SharedMessage>,
339    total_input_tokens: u64,
340    total_output_tokens: u64,
341    session_cost: f64,
342    abort_context: Option<String>,
343    continued: bool,
344    continue_info: Option<ContinueInfo>,
345}
346
347fn resolve_or_create_session(
348    runtime: &mut Runtime,
349    continue_session: &Option<Option<String>>,
350) -> Result<SessionBootResult> {
351    match continue_session {
352        Some(ref maybe_id) => {
353            let session = match maybe_id {
354                Some(ref id) => resolve_session(id).map_err(|e| {
355                    crate::error::RuntimeError::Tool(format!(
356                        "Failed to load session '{}': {}",
357                        id, e
358                    ))
359                })?,
360                None => latest_session().map_err(|e| {
361                    crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
362                })?,
363            };
364            runtime.set_model(session.model.clone());
365            // Restore the session's named reasoning level so max/ultra/off
366            // and custom budgets survive --continue.
367            if let Some(level) =
368                agent_core::reasoning::ReasoningLevel::parse(&session.thinking_level)
369            {
370                runtime.set_reasoning_level_explicit(level);
371            } else if let Some(budget) =
372                crate::models::budget_for_thinking_level(&session.thinking_level)
373            {
374                runtime.set_thinking_budget_explicit(budget);
375            }
376            if let Some(ref sp) = session.system_prompt {
377                runtime.set_system_prompt(sp.clone());
378            }
379
380            let continue_info = maybe_id.as_ref().map(|q| {
381                let resolved_via = if *q != session.id {
382                    if crate::chain::load_chain(q).is_ok() {
383                        Some("chain".to_string())
384                    } else if crate::session::find_session_by_name(q).is_ok() {
385                        Some("name".to_string())
386                    } else {
387                        None
388                    }
389                } else {
390                    None
391                };
392                ContinueInfo {
393                    session_id: session.id.clone(),
394                    resolved_via,
395                    query: q.clone(),
396                }
397            });
398
399            Ok(SessionBootResult {
400                api_messages: session.api_messages.clone(),
401                total_input_tokens: session.total_input_tokens,
402                total_output_tokens: session.total_output_tokens,
403                session_cost: session.session_cost,
404                abort_context: session.abort_context.clone(),
405                continued: true,
406                continue_info,
407                session,
408            })
409        }
410        None => {
411            let session = Session::new(
412                runtime.model(),
413                runtime.thinking_level(),
414                runtime.system_prompt(),
415            );
416            Ok(SessionBootResult {
417                session,
418                api_messages: Vec::new(),
419                total_input_tokens: 0,
420                total_output_tokens: 0,
421                session_cost: 0.0,
422                abort_context: None,
423                continued: false,
424                continue_info: None,
425            })
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use agent_core::reasoning::ReasoningLevel;
434
435    /// B1: --continue path must restore thinking_level from the saved session.
436    /// Simulates what resolve_or_create_session does when a session is continued.
437    #[test]
438    fn continue_path_restores_thinking_level_from_session() {
439        let mut runtime = Runtime::new_headless();
440
441        // Simulate what resolve_or_create_session does on --continue.
442        let thinking_level_str = "ultra";
443        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
444            runtime.set_reasoning_level_explicit(level);
445        }
446
447        assert_eq!(
448            runtime.reasoning_level(),
449            ReasoningLevel::Ultra,
450            "thinking level must be restored from session on --continue"
451        );
452        assert!(
453            runtime.is_reasoning_explicit(),
454            "restored thinking level must be marked explicit so set_model won't overwrite it"
455        );
456    }
457
458    #[test]
459    fn continue_path_restores_max_level() {
460        let mut runtime = Runtime::new_headless();
461        let thinking_level_str = "max";
462        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
463            runtime.set_reasoning_level_explicit(level);
464        }
465        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
466    }
467
468    #[test]
469    fn continue_path_restores_off_level() {
470        let mut runtime = Runtime::new_headless();
471        let thinking_level_str = "off";
472        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
473            runtime.set_reasoning_level_explicit(level);
474        }
475        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
476    }
477}