//! Domain bundles: the managers, grouped by cohesion, that make up `Skald`. //! //! Each bundle owns a `build()` that constructs its managers (plus their startup //! logging and non-fatal `seed_*` calls) from the shared [`Runtime`] and whatever //! sibling bundles it depends on at construction time. Cross-bundle *cycles* are //! not expressed here — they are resolved by the managers' `OnceLock` setters, //! called in one place by [`super::wiring::wire`]. Bundle structs never hold //! references to each other. use std::sync::Arc; use anyhow::Result; use tracing::{debug, info, warn}; use core_api::remote::RemoteAccess; use crate::approval::ApprovalManager; use crate::chat_hub::ChatHub; use crate::clarification::ClarificationManager; use crate::command::LlmCommandManager; use crate::compactor::ContextCompactor; use crate::config::{CoreConfig, DatetimeConfig}; use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; use crate::git_versions::GitVersions; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; use crate::mcp::McpManager; use crate::memory::MemoryManager; use crate::plugin::PluginManager; use crate::provider::ProviderRegistry; use crate::run_context::RunContextManager; use crate::secrets::SecretsStore; use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; use crate::session::manager::ChatSessionManager; use crate::tool_catalog::ToolCatalog; use crate::tool_discovery::ToolDiscovery; use crate::tools::ToolRegistry; use crate::transcribe::TranscribeManager; use crate::tts::TtsManager; use tokio::sync::RwLock; use core_api::plugin::Plugin; use core_api::provider::ApiProvider; use super::runtime::Runtime; // ── Models: LLM/provider stack ────────────────────────────────────────────── pub(super) struct Models { pub(super) provider_registry: Arc, pub(super) llm_manager: Arc, pub(super) secrets: Arc, pub(super) memory_manager: Arc, } impl Models { pub(super) async fn build(rt: &Runtime, config: &CoreConfig) -> Result { let mut provider_registry = ProviderRegistry::new(Arc::clone(&rt.system_bus)); provider_registry.register_builtin(crate::llm::providers::openai::OpenAiProvider); provider_registry.register_builtin(crate::llm::providers::anthropic::AnthropicProvider::new()); provider_registry.register_builtin(crate::llm::providers::openrouter::OpenRouterProvider::new()); provider_registry.register_builtin(crate::llm::providers::requesty::RequestyProvider::new()); provider_registry.register_builtin(crate::llm::providers::ollama::OllamaProvider::new()); // OpenAI-compatible providers are runtime data (providers.yaml), not code. for p in crate::llm::providers::declared::load(std::path::Path::new( crate::llm::providers::declared::PROVIDERS_FILE, )) { if provider_registry.contains(p.type_id()) { warn!(type_id = p.type_id(), "declared provider id collides with a native provider — skipped"); continue; } provider_registry.register_builtin(p); } let provider_registry = Arc::new(provider_registry); info!("provider registry ready ({} built-in providers)", provider_registry.all().len()); let log_enabled = config.llm.requests_log.as_ref().is_some_and(|r| r.enabled); let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_enabled).await?; let client_count = llm_manager.client_names().await.len().saturating_sub(1); let default_client = llm_manager.default_name().await; info!(clients = client_count, default = %default_client, "LLM clients loaded"); let secrets = SecretsStore::new(Arc::clone(&rt.db)); info!("secrets store ready"); let memory_manager = Arc::new(MemoryManager::new()); info!("memory manager ready"); Ok(Models { provider_registry, llm_manager, secrets, memory_manager }) } } // ── Media: transcription / TTS / image generation ─────────────────────────── pub(super) struct Media { pub(super) image_generator_manager: Arc, pub(super) transcribe_manager: Arc, pub(super) tts_manager: Arc, } impl Media { pub(super) async fn build(rt: &Runtime, models: &Models) -> Result { let image_generator_manager = ImageGeneratorManager::new( Arc::clone(&rt.db), Arc::clone(&models.provider_registry), ).await?; // Evaluate the await outside the `info!` macro: leaving the temporary // `tracing::Value` from the field expression alive across the await // makes the surrounding future non-Send, which the multi-threaded // runtime rejects. let image_generator_models = image_generator_manager.list_models_info().await.len(); info!( db_backed = image_generator_models, "image generator manager ready", ); let transcribe_manager = TranscribeManager::new( Arc::clone(&rt.db), Arc::clone(&models.provider_registry), Arc::clone(&rt.system_bus), rt.shutdown_token.clone(), ).await?; let transcribe_models = transcribe_manager.list_models_info().await.len(); info!( db_backed = transcribe_models, "transcribe manager ready", ); let tts_manager = TtsManager::new( Arc::clone(&rt.db), Arc::clone(&models.provider_registry), Arc::clone(&rt.system_bus), rt.shutdown_token.clone(), ).await?; let tts_models = tts_manager.list_models_info().await.len(); info!( db_backed = tts_models, "tts manager ready", ); Ok(Media { image_generator_manager, transcribe_manager, tts_manager }) } } // ── Integrations: MCP + plugins ───────────────────────────────────────────── pub(super) struct Integrations { pub(super) mcp: Arc, pub(super) plugin_manager: Arc, } impl Integrations { /// Builds the MCP manager (its `initialize()` is deferred to `spawn_background`, /// after the elicitation handler is wired) and the plugin manager (plugins are /// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`). pub(super) fn build(rt: &Runtime, plugins: Vec>) -> Self { // The global runtime has no owner, so its notifications are not persisted: // `mcp_events` is per-user and its only reader (event triage) runs per-user. let mcp = Arc::new(McpManager::new( Arc::clone(&rt.db), rt.shutdown_token.clone(), "data", crate::mcp::EventLog::Discard, )); // Supervise the global connectors: a crashed one is restarted rather than // staying dead until the process does. Spawned post-construction because the // sweep reacts through the `Arc` it is watching (see `spawn_respawn_sweep`). mcp.spawn_respawn_sweep(rt.shutdown_token.clone()); let mut plugin_manager = PluginManager::new(Arc::clone(&rt.db)); for plugin in plugins { plugin_manager.register_arc(plugin); } info!("plugins registered"); let plugin_manager = Arc::new(plugin_manager); Integrations { mcp, plugin_manager } } } // ── Tasks: cron ────────────────────────────────────────────────────────────── pub(super) struct Tasks { pub(super) cron: Arc, } impl Tasks { /// Built before `Tools` so cron tools can capture the `TaskManager`. pub(super) fn build(rt: &Runtime, config: &CoreConfig) -> Self { let cron_tz = config.timezone.as_deref().and_then(|s| { match s.parse::() { Ok(tz) => { info!("timezone: using {s}"); Some(tz) } Err(_) => { warn!("timezone: unknown value '{s}', falling back to local time"); None } } }); let cron = TaskManager::new(Arc::clone(&rt.db), cron_tz, Arc::clone(&rt.system_bus)); Tasks { cron } } } // ── Tools: registry + catalog + slash commands ────────────────────────────── pub(super) struct Tools { pub(super) tools: Arc, pub(super) catalog: ToolCatalog, pub(super) command_manager: Arc, } impl Tools { /// Captures sibling managers (mcp, plugins, cron, secrets) into the tool /// registry. `execute_task` is deliberately NOT registered here — it is injected /// per interactive session by `ChatHub::send_message`. pub(super) fn build(rt: &Runtime, integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self { let mut tool_registry = ToolRegistry::new(); crate::tools::fs::register_all(&mut tool_registry, Arc::clone(&rt.db)); tool_registry.register(crate::tools::ast_outline::AstOutline::new(Arc::clone(&rt.db))); tool_registry.register(crate::tools::exec::ExecuteCmd); tool_registry.register(crate::tools::read_notification::ReadNotification); // Unified listing / toggling across plugins, cron (+ agents and MCP for // list). MCP is listed but never agent-*managed* (blueprint §14): // connectors are curated by the admin and activated by the user via the // Connectors UI/API — hence `list_items` gained the type and // `toggle_item` deliberately did not. tool_registry.register(crate::tools::list_items::ListItems::new( Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron), Arc::clone(&rt.db))); tool_registry.register(crate::tools::toggle_item::ToggleItem::new( Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); tool_registry.register(crate::tools::cron_jobs::DeleteCronJob); tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager))); // The whole write surface of the read-only skills trees (blueprint §7.3): // `Config`-category, so neither appears in a request's schema until // `activate_tools(["config"])` asks. They take the registry to read the // caller's role (`skill.manage` gates the group's scope) and the cell // `Skald::new` later fills, through which an installation reaches // conversations that are already running. tool_registry.register(crate::tools::skills::SkillRegister::new( Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes))); tool_registry.register(crate::tools::skills::SkillDelete::new( Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes))); // The download half of the skills lifecycle (blueprint §7.5): same // `Config` category, so it too stays out of every request's schema // until `activate_tools(["config"])`. It needs no state of its own — // the container it runs git in comes from each caller's `ToolContext`. tool_registry.register(crate::tools::fetch_repo::FetchRepo); // Tools contributed by plugins (plugin.md §11), via `Plugin::tools()`. // The core never names a plugin crate: each one hands over whatever tools // it wants, bound to its own handle. They are built before the plugins' // runloops start, so they must tolerate being called while stopped. for plugin in integrations.plugin_manager.all() { let id = plugin.id().to_string(); let tools = Arc::clone(plugin).tools(); if tools.is_empty() { continue; } let n = tools.len(); for tool in tools { tool_registry.register_arc(tool); } info!(plugin = %id, count = n, "plugin tools registered"); } debug!("tool registry built"); let tools = Arc::new(tool_registry); let catalog = ToolCatalog::new(Arc::clone(&tools), Arc::clone(&integrations.mcp)); let command_manager = Arc::new(LlmCommandManager::new()); Tools { tools, catalog, command_manager } } } // ── Interaction: approval + inbox + clarification + elicitation ───────────── pub(super) struct Interaction { pub(super) approval: Arc, pub(super) inbox: Inbox, pub(super) clarification: Arc, pub(super) elicitation: Arc, } impl Interaction { pub(super) async fn build(rt: &Runtime, tools: &Tools) -> Result { let approval = Arc::new(ApprovalManager::new(Arc::clone(&rt.db), rt.global_tx.clone())); if let Err(e) = approval.seed_defaults().await { warn!(error = %e, "failed to seed default approval rules (non-fatal)"); } if let Err(e) = approval.migrate_legacy_fs_rules().await { warn!(error = %e, "failed to migrate legacy filesystem rules (non-fatal)"); } if let Err(e) = approval.seed_fs_path_rules().await { warn!(error = %e, "failed to seed File System path rules (non-fatal)"); } if let Err(e) = approval.seed_default_catch_all().await { warn!(error = %e, "failed to seed default catch-all rule (non-fatal)"); } info!("approval manager ready"); // Shared memory is owned by the system database, so its skeleton is // seeded here rather than in `initialize_instance` — idempotent, so an // instance that predates the memory wiki gets it on its next boot. if let Err(e) = crate::memory::scaffold::seed_shared(&rt.db).await { warn!(error = %e, "failed to seed shared memory scaffold (non-fatal)"); } let clarification = ClarificationManager::new(rt.global_tx.clone()); let elicitation = ElicitationManager::new(rt.global_tx.clone()); let inbox = Inbox::new( Arc::clone(&approval), Arc::clone(&clarification), Arc::clone(&elicitation), Arc::clone(&tools.tools), ); Ok(Interaction { approval, inbox, clarification, elicitation }) } } // ── Conversation: session manager + chat hub + run context ────────────────── pub(super) struct Conversation { pub(super) manager: Arc, pub(super) chat_hub: Arc, pub(super) run_context_manager: Arc, } impl Conversation { #[allow(clippy::too_many_arguments)] pub(super) async fn build( rt: &Runtime, models: &Models, media: &Media, tools: &Tools, integrations: &Integrations, interaction: &Interaction, config: &CoreConfig, ) -> Result { let run_context_manager = Arc::new(RunContextManager::new(Arc::clone(&rt.db), Arc::clone(&interaction.approval))); if let Err(e) = run_context_manager.seed_defaults().await { warn!(error = %e, "failed to seed default permission group (non-fatal)"); } info!("run_context manager ready"); // Always built: `/compact` is a manual command and must work with no // configuration. Only the automatic trigger is opt-in (`threshold_tokens`). let compactor = { let cfg = &config.llm.compaction; match cfg.threshold_tokens { Some(threshold_tokens) => info!( threshold_tokens, keep_recent = cfg.keep_recent, ?cfg.strength, "context compactor ready (automatic compaction enabled)" ), None => info!( "context compactor ready (automatic compaction off — /compact only)" ), } Arc::new(ContextCompactor::new( cfg.clone(), Arc::clone(&models.llm_manager), Arc::clone(&rt.event_bus), Arc::clone(&rt.config), )) }; // The ownerless manager is inert (no loops, no consumers — see §19): it takes // a placeholder UserFs purely to satisfy the type, never used to resolve a path. let ownerless_fs = core_api::user_fs::SharedFs::new(core_api::user_fs::UserFs::new( String::new(), std::path::PathBuf::from("homes"), "skald-ownerless", std::path::PathBuf::from("/root"), Vec::new(), Vec::new(), None, )); let manager = Arc::new(ChatSessionManager::new( Arc::clone(&rt.db), Arc::clone(&rt.db), // shared pool == system.db (this is the ownerless manager) String::new(), ownerless_fs, Arc::clone(&models.llm_manager), config.llm.max_history_messages, config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS), config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS), config.llm.max_tool_result_chars, DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime }, // No container, no probe: this bundle is inert (§19). Arc::new(Vec::new()), Arc::clone(&tools.tools), // Inert ownerless bundle (§19): the global runtime as a provider, // unfiltered — never actually exercised (no loops, no consumers). Arc::clone(&integrations.mcp) as Arc, Arc::clone(&interaction.approval), Arc::clone(&interaction.clarification), Arc::clone(&rt.event_bus), Arc::clone(&models.memory_manager), Arc::clone(&media.image_generator_manager), compactor, Arc::clone(&run_context_manager), Arc::new(ToolDiscovery::new(Arc::clone(&rt.db))), )?); let chat_hub = ChatHub::new( Arc::clone(&rt.db), Arc::clone(&manager), Arc::clone(&interaction.approval), rt.global_tx.clone(), rt.shutdown_token.clone(), // Inert ownerless bundle (§19): no owner to resolve a role default from, so // the neutral fallback. Nothing consumes this hub's sessions. crate::agents::DEFAULT_CHAT_AGENT.to_string(), ); chat_hub.register("web").await; chat_hub.register("talk").await; // Event triage is deliberately absent: it is a system agent that runs *per user*, // over that user's own events, sessions and hub. Building it here would // bind it to the ownerless stack above (§19) — which is precisely the bug // that made it inert. It is constructed by `wiring::spawn_system_agents`. Ok(Conversation { manager, chat_hub, run_context_manager }) } } // ── Infra: leftover singletons ────────────────────────────────────────────── pub(super) struct Infra { pub(super) latex_compiler: LatexCompiler, pub(super) git_versions: GitVersions, pub(super) location_manager: Arc, pub(super) remote: Arc>>>, } impl Infra { pub(super) fn build() -> Self { Infra { latex_compiler: LatexCompiler::new(), git_versions: GitVersions::new(), location_manager: Arc::new(LocationManager::new()), remote: Arc::new(RwLock::new(None)), } } }