1use crate::skills::keybinds::KeybindRegistry;
7use crate::skills::registry::CommandRegistry;
8use crate::{latest_session, resolve_session, EngineHost, HostOpts, Result, Runtime, Session};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12pub 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>,
20 pub no_extensions: bool,
21}
22
23pub struct BackgroundTasks {
25 watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
26 watcher_task: tokio::task::JoinHandle<()>,
27 socket_shutdown: Arc<std::sync::atomic::AtomicBool>,
28 socket_task: tokio::task::JoinHandle<()>,
29 #[allow(dead_code)] session_socket_path: String,
31 session_id: String,
32 hook_bus: Arc<crate::extensions::hooks::HookBus>,
35 #[allow(dead_code)]
41 log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
42}
43
44impl BackgroundTasks {
45 pub fn shutdown(&self) {
47 self.watcher_shutdown
48 .store(true, std::sync::atomic::Ordering::Release);
49 self.socket_shutdown
50 .store(true, std::sync::atomic::Ordering::Release);
51 crate::events::registry::unregister_session(&self.session_id);
52 if let Ok(handle) = tokio::runtime::Handle::try_current() {
54 let hook_bus = Arc::clone(&self.hook_bus);
55 let session_id = self.session_id.clone();
56 handle.spawn(async move {
57 hook_bus.clear_session_injection(&session_id).await;
58 });
59 }
60 }
61}
62
63impl Drop for BackgroundTasks {
64 fn drop(&mut self) {
65 self.watcher_shutdown
66 .store(true, std::sync::atomic::Ordering::Relaxed);
67 self.socket_shutdown
68 .store(true, std::sync::atomic::Ordering::Relaxed);
69 self.watcher_task.abort();
70 self.socket_task.abort();
71 }
72}
73
74pub struct EngineBoot {
76 pub runtime: Runtime,
77 pub config: crate::SynapsConfig,
78 pub no_extensions: bool,
81 pub session: Session,
82 pub api_messages: Vec<crate::SharedMessage>,
83 pub total_input_tokens: u64,
84 pub total_output_tokens: u64,
85 pub session_cost: f64,
86 pub abort_context: Option<String>,
87 pub continued: bool,
88 pub continue_info: Option<ContinueInfo>,
89 pub registry: Arc<CommandRegistry>,
90 pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
95 pub mcp_server_count: usize,
96 pub system_prompt_path: std::path::PathBuf,
97 pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
98 pub background: BackgroundTasks,
100}
101
102pub struct ContinueInfo {
104 pub session_id: String,
105 pub resolved_via: Option<String>, pub query: String,
107 pub compaction_notice: Option<String>,
109}
110
111pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
114 let host = EngineHost::boot_and_install(HostOpts {
120 profile: opts.profile.clone(),
121 no_extensions: opts.no_extensions,
122 })
123 .await?;
124 let mut runtime = host.foreground_runtime().await?;
127 let config: crate::SynapsConfig = (**host.config()).clone();
128
129 let sb = resolve_session_and_prompt(
130 &mut runtime,
131 &opts.continue_session,
132 opts.system.as_deref(),
133 opts.prompt_manifest.as_deref(),
134 )?;
135
136 let registry = Arc::clone(host.command_registry());
139 let keybind_registry = Arc::clone(host.keybind_registry());
140 let mcp_server_count = host.mcp_server_count();
141
142 let system_prompt_path = crate::config::resolve_read_path("system.md");
143
144 let background = spawn_session_background(&runtime, &sb.session)?;
148
149 finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
150
151 let ext_manager = Arc::clone(host.ext_manager());
153
154 if mcp_server_count > 0 {
155 tracing::info!(
156 "{} MCP servers available (use connect_mcp_server to activate)",
157 mcp_server_count
158 );
159 }
160
161 Ok(EngineBoot {
162 runtime,
163 config,
164 no_extensions: opts.no_extensions,
165 session: sb.session,
166 api_messages: sb.api_messages,
167 total_input_tokens: sb.total_input_tokens,
168 total_output_tokens: sb.total_output_tokens,
169 session_cost: sb.session_cost,
170 abort_context: sb.abort_context,
171 continued: sb.continued,
172 continue_info: sb.continue_info,
173 registry,
174 keybind_registry,
175 mcp_server_count,
176 system_prompt_path,
177 ext_manager,
178 background,
179 })
180}
181
182pub(crate) fn resolve_session_and_prompt(
185 runtime: &mut Runtime,
186 continue_session: &Option<Option<String>>,
187 system: Option<&str>,
188 prompt_manifest: Option<&std::path::Path>,
189) -> Result<SessionBootResult> {
190 let sb = resolve_or_create_session(runtime, continue_session)?;
193 runtime.set_session_id(Some(sb.session.id.clone()));
194 runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
197
198 let legacy_prompt = crate::config::resolve_system_prompt(system);
200 if let Some(path) = prompt_manifest {
201 let raw = std::fs::read_to_string(path)
202 .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
203 let manifest = agent_core::prompt::PromptManifest::parse(&raw)
204 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
205 let registry = manifest
206 .registry(path.parent())
207 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
208 let model = crate::orchestration::canonical_foreground_identity(runtime.model())
209 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
210 let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
211 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
212 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
213 &model,
214 manifest.delegation_catalog_candidates(),
215 )
216 .map_err(|error| crate::RuntimeError::Config(error.into()))?;
217 let delegation_policy = manifest
218 .delegation_policy(model.clone(), &catalog)
219 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
220 let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
221 if let Some(policy) = delegation_policy {
222 runtime.install_orchestration(Arc::new(
223 crate::orchestration::OrchestrationRuntime::new(policy),
224 ));
225 } else {
226 runtime.install_orchestration(Arc::new(
227 crate::orchestration::OrchestrationRuntime::baseline(model.clone(), 8, 64)
228 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
229 ));
230 }
231 let user = system
232 .map(|_| {
233 agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
234 })
235 .transpose()
236 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
237 let stack =
238 agent_core::prompt::compile_prompt_stack(&manifest, ®istry, &context, user.clone())
239 .map_err(|e| {
240 crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
241 })?;
242 runtime
243 .apply_prompt_stack(stack)
244 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
245 runtime.retain_prompt_reload_source(
246 path.to_path_buf(),
247 context,
248 user,
249 delegation_policy_digest,
250 );
251 } else {
252 let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
253 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
254 runtime.install_orchestration(Arc::new(
255 crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
256 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
257 ));
258 runtime.set_system_prompt(legacy_prompt);
259 }
260
261 Ok(sb)
262}
263
264pub(crate) fn spawn_session_background(
267 runtime: &Runtime,
268 session: &Session,
269) -> Result<BackgroundTasks> {
270 let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
272 let watcher_task = {
273 let inbox_dir = crate::config::base_dir().join("inbox");
274 let event_queue = runtime.event_queue().clone();
275 let shutdown = watcher_shutdown.clone();
276 tokio::spawn(async move {
277 crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
278 })
279 };
280
281 let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
283 wt: &tokio::task::JoinHandle<()>| {
284 ws.store(true, std::sync::atomic::Ordering::Relaxed);
285 wt.abort();
286 };
287
288 let socket_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
290 let session_socket_path = crate::events::registry::socket_path_for_session(&session.id);
291 let socket_task = crate::events::socket::listen_session_socket(
292 session_socket_path.clone(),
293 runtime.event_queue().clone(),
294 socket_shutdown.clone(),
295 );
296 let session_registration = crate::events::registry::SessionRegistration {
297 kind: crate::events::registry::REGISTRATION_KIND.to_string(),
298 session_id: session.id.clone(),
299 name: session.name.clone(),
300 socket_path: session_socket_path.clone(),
301 pid: std::process::id(),
302 started_at: chrono::Utc::now(),
303 };
304 if let Err(e) = crate::events::registry::register_session(&session_registration) {
305 abort_tasks(&watcher_shutdown, &watcher_task);
306 socket_shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
307 socket_task.abort();
308 return Err(crate::core::error::RuntimeError::Session(format!(
313 "failed to register session {}: {}",
314 session_registration.session_id, e
315 )));
316 }
317
318 Ok(BackgroundTasks {
319 watcher_shutdown,
320 watcher_task,
321 socket_shutdown,
322 socket_task,
323 session_socket_path,
324 session_id: session.id.clone(),
325 hook_bus: Arc::clone(runtime.hook_bus()),
326 log_guard: None,
328 })
329}
330
331#[derive(Clone, Copy, Debug, PartialEq, Eq)]
334pub(crate) enum IndexRecord {
335 Start,
336 Skip,
337}
338
339pub(crate) fn finish_session_setup(
342 runtime: &mut Runtime,
343 config: &crate::SynapsConfig,
344 session: &Session,
345 cwd: Option<std::path::PathBuf>,
346 index_record: IndexRecord,
347) {
348 runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
351 crate::runtime::budget::TurnRole::Foreground,
352 &config.turn_budgets,
353 ));
354
355 if index_record == IndexRecord::Start {
364 let mut index_record =
365 crate::core::session_index::SessionIndexRecord::start(&session.id);
366 index_record.model = Some(session.model.clone());
367 index_record.profile = crate::core::config::get_profile();
368 index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
369 if let Err(err) = crate::core::session_index::append_record(&index_record) {
370 tracing::warn!("failed to append session start index record: {}", err);
371 }
372 }
373
374}
375
376pub(crate) struct SessionBootResult {
378 pub(crate) session: Session,
379 pub(crate) api_messages: Vec<crate::SharedMessage>,
380 pub(crate) total_input_tokens: u64,
381 pub(crate) total_output_tokens: u64,
382 pub(crate) session_cost: f64,
383 pub(crate) abort_context: Option<String>,
384 pub(crate) continued: bool,
385 pub(crate) continue_info: Option<ContinueInfo>,
386}
387
388fn resolve_or_create_session(
389 runtime: &mut Runtime,
390 continue_session: &Option<Option<String>>,
391) -> Result<SessionBootResult> {
392 match continue_session {
393 Some(ref maybe_id) => {
394 let raw_session = match maybe_id {
395 Some(ref id) => resolve_session(id).map_err(|e| {
396 crate::error::RuntimeError::Tool(format!(
397 "Failed to load session '{}': {}",
398 id, e
399 ))
400 })?,
401 None => latest_session().map_err(|e| {
402 crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
403 })?,
404 };
405 let resolved = agent_core::session::follow_compaction_chain(raw_session)
408 .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
409 let compaction_notice = resolved.compaction_notice;
410 let mut session = resolved.session;
411 runtime.set_model(session.model.clone());
412 if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
417 tracing::warn!(
418 from = %clamp.from,
419 to = %clamp.to,
420 model = %session.model,
421 "saved session thinking level not supported by model; clamped"
422 );
423 session.thinking_level = runtime.thinking_level().to_string();
424 }
425 if let Some(ref sp) = session.system_prompt {
426 runtime.set_system_prompt(sp.clone());
427 }
428
429 let continue_info = {
430 let (resolved_via, query) = match maybe_id {
431 Some(ref q) => {
432 let via = if *q != session.id {
433 if crate::chain::load_chain(q).is_ok() {
434 Some("chain".to_string())
435 } else if agent_core::session::find_session_by_name(q).is_ok() {
436 Some("name".to_string())
437 } else if compaction_notice.is_some() {
438 Some("compacted".to_string())
439 } else {
440 None
441 }
442 } else {
443 None
444 };
445 (via, q.clone())
446 }
447 None => {
448 let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
449 (via, session.id.clone())
450 }
451 };
452 if maybe_id.is_some() || compaction_notice.is_some() {
455 Some(ContinueInfo {
456 session_id: session.id.clone(),
457 resolved_via,
458 query,
459 compaction_notice: compaction_notice.clone(),
460 })
461 } else {
462 None
463 }
464 };
465
466 Ok(SessionBootResult {
467 api_messages: session.api_messages.clone(),
468 total_input_tokens: session.total_input_tokens,
469 total_output_tokens: session.total_output_tokens,
470 session_cost: session.session_cost,
471 abort_context: session.abort_context.clone(),
472 continued: true,
473 continue_info,
474 session,
475 })
476 }
477 None => {
478 let session = Session::new(
479 runtime.model(),
480 runtime.thinking_level(),
481 runtime.system_prompt(),
482 );
483 Ok(SessionBootResult {
484 session,
485 api_messages: Vec::new(),
486 total_input_tokens: 0,
487 total_output_tokens: 0,
488 session_cost: 0.0,
489 abort_context: None,
490 continued: false,
491 continue_info: None,
492 })
493 }
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use agent_core::reasoning::ReasoningLevel;
501
502 #[test]
505 fn continue_path_restores_thinking_level_from_session() {
506 let mut runtime = Runtime::new_headless();
507
508 let thinking_level_str = "ultra";
510 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
511 runtime.set_reasoning_level_explicit(level);
512 }
513
514 assert_eq!(
515 runtime.reasoning_level(),
516 ReasoningLevel::Ultra,
517 "thinking level must be restored from session on --continue"
518 );
519 assert!(
520 runtime.is_reasoning_explicit(),
521 "restored thinking level must be marked explicit so set_model won't overwrite it"
522 );
523 }
524
525 #[test]
526 fn continue_path_restores_max_level() {
527 let mut runtime = Runtime::new_headless();
528 let thinking_level_str = "max";
529 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
530 runtime.set_reasoning_level_explicit(level);
531 }
532 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
533 }
534
535 #[test]
536 fn continue_path_restores_off_level() {
537 let mut runtime = Runtime::new_headless();
538 let thinking_level_str = "off";
539 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
540 runtime.set_reasoning_level_explicit(level);
541 }
542 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
543 }
544
545 #[test]
548 fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
549 let runtime = Runtime::new_headless();
550 assert!(
552 !runtime.memory_backend_exclusive(),
553 "default config must NOT be exclusive (Axel); it must be legacy"
554 );
555 let tools = runtime.tools_shared();
557 let registry = tools.blocking_read();
558 let has_checkpoint = registry
559 .iter_tools_sorted()
560 .iter()
561 .any(|t| t.name() == "context_checkpoint");
562 assert!(
563 !has_checkpoint,
564 "context_checkpoint must not appear in the default tool set"
565 );
566 }
567}