Skip to main content

walrus_daemon/daemon/
builder.rs

1//! Daemon construction and lifecycle methods.
2//!
3//! This module provides the [`Daemon`] builder and reload logic as private
4//! `impl Daemon` methods. [`Daemon::build`] constructs a fully-configured
5//! daemon from a [`DaemonConfig`]. [`Daemon::reload`] rebuilds the runtime
6//! in-place from disk without restarting transports.
7
8use crate::{
9    Daemon, DaemonConfig,
10    daemon::event::{DaemonEvent, DaemonEventSender},
11    ext::hub::DownloadRegistry,
12    hook::{
13        self, DaemonHook,
14        system::{memory::BuiltinMemory, task::TaskRegistry},
15    },
16    service::ServiceManager,
17};
18use anyhow::Result;
19use compact_str::CompactString;
20use model::ProviderRegistry;
21use std::{path::Path, sync::Arc};
22use tokio::sync::{Mutex, RwLock};
23use wcore::{AgentConfig, Runtime, ToolRequest};
24
25const SYSTEM_AGENT: &str = include_str!("../../prompts/walrus.md");
26const SKILL_MASTER_AGENT: &str = include_str!("../../prompts/skill-master.md");
27
28impl Daemon {
29    /// Build a fully-configured [`Daemon`] from the given config, config
30    /// directory, and event sender. Returns the daemon and an optional
31    /// ServiceManager for lifecycle management of child services.
32    pub(crate) async fn build(
33        config: &DaemonConfig,
34        config_dir: &Path,
35        event_tx: DaemonEventSender,
36    ) -> Result<(Self, Option<ServiceManager>)> {
37        let (runtime, service_manager) = Self::build_runtime(config, config_dir, &event_tx).await?;
38        Ok((
39            Self {
40                runtime: Arc::new(RwLock::new(Arc::new(runtime))),
41                config_dir: config_dir.to_path_buf(),
42                event_tx,
43            },
44            service_manager,
45        ))
46    }
47
48    /// Rebuild the runtime from disk and swap it in atomically.
49    ///
50    /// In-flight requests that already hold a reference to the old runtime
51    /// complete normally. New requests after the swap see the new runtime.
52    /// Note: reload does not restart managed services — that requires a
53    /// full daemon restart. Services field is cleared to avoid re-spawning.
54    pub async fn reload(&self) -> Result<()> {
55        let mut config = DaemonConfig::load(&self.config_dir.join("walrus.toml"))?;
56        config.services.clear();
57        let (new_runtime, _) =
58            Self::build_runtime(&config, &self.config_dir, &self.event_tx).await?;
59        *self.runtime.write().await = Arc::new(new_runtime);
60        tracing::info!("daemon reloaded");
61        Ok(())
62    }
63
64    /// Construct a fresh [`Runtime`] from config. Used by both [`build`] and [`reload`].
65    /// Returns the runtime and an optional ServiceManager for child service lifecycle.
66    async fn build_runtime(
67        config: &DaemonConfig,
68        config_dir: &Path,
69        event_tx: &DaemonEventSender,
70    ) -> Result<(
71        Runtime<ProviderRegistry, DaemonHook>,
72        Option<ServiceManager>,
73    )> {
74        let manager = Self::build_providers(config)?;
75        let (hook, service_manager) =
76            Self::build_hook(config, config_dir, event_tx, &manager).await?;
77        let tool_tx = Self::build_tool_sender(event_tx);
78        let mut runtime = Runtime::new(manager, hook, Some(tool_tx)).await;
79        // Set compact hook on runtime for auto-compaction.
80        if let Some(ref registry) = runtime.hook.registry {
81            runtime.set_compact_hook(Arc::clone(registry) as Arc<dyn wcore::CompactHook>);
82        }
83        Self::load_agents(&mut runtime, config_dir, config)?;
84        Ok((runtime, service_manager))
85    }
86
87    /// Construct the provider registry from config.
88    ///
89    /// Builds remote providers from config and sets the active model.
90    fn build_providers(config: &DaemonConfig) -> Result<ProviderRegistry> {
91        let active_model = config
92            .system
93            .walrus
94            .model
95            .clone()
96            .ok_or_else(|| anyhow::anyhow!("system.walrus.model is required in walrus.toml"))?;
97        let registry = ProviderRegistry::from_providers(active_model, &config.provider)?;
98
99        tracing::info!(
100            "provider registry initialized — active model: {}",
101            registry.active_model_name().unwrap_or_default()
102        );
103        Ok(registry)
104    }
105
106    /// Build the daemon hook with all backends (skills, MCP, tasks, downloads, memory).
107    /// Built-in memory is active unless the walrus-memory extension provides `recall`.
108    /// Returns the hook and an optional ServiceManager for child service lifecycle.
109    async fn build_hook(
110        config: &DaemonConfig,
111        config_dir: &Path,
112        event_tx: &DaemonEventSender,
113        manager: &ProviderRegistry,
114    ) -> Result<(DaemonHook, Option<ServiceManager>)> {
115        let downloads = Arc::new(Mutex::new(DownloadRegistry::new()));
116
117        let skills_dir = config_dir.join(wcore::paths::SKILLS_DIR);
118        let skills = hook::skill::SkillHandler::load(skills_dir).unwrap_or_else(|e| {
119            tracing::warn!("failed to load skills: {e}");
120            hook::skill::SkillHandler::default()
121        });
122
123        let mcp_servers = config.mcps.values().cloned().collect::<Vec<_>>();
124        let mcp_handler = hook::mcp::McpHandler::load(&mcp_servers).await;
125
126        let task_timeout = std::time::Duration::from_secs(config.system.tasks.task_timeout);
127        let tasks = Arc::new(Mutex::new(TaskRegistry::new(
128            config.system.tasks.max_concurrent,
129            config.system.tasks.viewable_window,
130        )));
131
132        let sandboxed = detect_sandbox();
133        if sandboxed {
134            tracing::info!("sandbox mode active — OS tools bypass permission check");
135        }
136
137        // Spawn and handshake managed services.
138        let (registry, service_manager) = if config.services.is_empty() {
139            (None, None)
140        } else {
141            let daemon_socket = wcore::paths::SOCKET_PATH.to_path_buf();
142            let mut sm = ServiceManager::new(&config.services, config_dir, daemon_socket);
143            sm.spawn_all().await?;
144            let mut registry = sm.handshake_all().await;
145            // Set model for Infer fulfillment before wrapping in Arc.
146            registry.set_model(manager.clone());
147            (Some(Arc::new(registry)), Some(sm))
148        };
149
150        // Construct built-in memory unless the walrus-memory extension provides "recall".
151        let has_ext_memory = registry
152            .as_ref()
153            .is_some_and(|r| r.tools.contains_key("recall"));
154        let memory = if !has_ext_memory {
155            Some(BuiltinMemory::open(
156                config_dir.join("memory"),
157                config.system.memory.clone(),
158            ))
159        } else {
160            None
161        };
162
163        Ok((
164            DaemonHook::new(
165                skills,
166                mcp_handler,
167                tasks,
168                downloads,
169                config.permissions.clone(),
170                sandboxed,
171                memory,
172                registry,
173                event_tx.clone(),
174                task_timeout,
175            ),
176            service_manager,
177        ))
178    }
179
180    /// Build a [`ToolSender`] that forwards [`ToolRequest`]s into the daemon
181    /// event loop as [`DaemonEvent::ToolCall`] variants.
182    ///
183    /// Spawns a lightweight bridge task relaying from the tool channel into
184    /// the main daemon event channel.
185    fn build_tool_sender(event_tx: &DaemonEventSender) -> wcore::ToolSender {
186        let (tool_tx, mut tool_rx) = tokio::sync::mpsc::unbounded_channel::<ToolRequest>();
187        let event_tx = event_tx.clone();
188        tokio::spawn(async move {
189            while let Some(req) = tool_rx.recv().await {
190                if event_tx.send(DaemonEvent::ToolCall(req)).is_err() {
191                    break;
192                }
193            }
194        });
195        tool_tx
196    }
197
198    /// Load agents and add them to the runtime.
199    ///
200    /// The built-in walrus agent is always registered first. Sub-agents are
201    /// loaded by iterating TOML `[agents.*]` entries and matching each to a
202    /// `.md` prompt file from the agents directory.
203    fn load_agents(
204        runtime: &mut Runtime<ProviderRegistry, DaemonHook>,
205        config_dir: &Path,
206        config: &DaemonConfig,
207    ) -> Result<()> {
208        // Load prompt files from disk: (filename_stem, text).
209        let prompts = crate::config::load_agents_dir(&config_dir.join(wcore::paths::AGENTS_DIR))?;
210        let prompt_map: std::collections::BTreeMap<String, String> = prompts.into_iter().collect();
211
212        // Built-in walrus agent.
213        let mut walrus_config = config.system.walrus.clone();
214        walrus_config.name = CompactString::from(wcore::paths::DEFAULT_AGENT);
215        walrus_config.system_prompt = SYSTEM_AGENT.to_owned();
216        runtime.add_agent(walrus_config);
217
218        // Built-in skill-master agent.
219        let mut skill_master = AgentConfig::new("skill-master");
220        skill_master.system_prompt = SKILL_MASTER_AGENT.to_owned();
221        skill_master.description = CompactString::from("Interactive skill recorder");
222        skill_master.thinking = config.system.walrus.thinking;
223        runtime.add_agent(skill_master);
224
225        // Sub-agents from TOML — each must have a matching .md file.
226        for (name, agent_config) in &config.agents {
227            let Some(prompt) = prompt_map.get(name) else {
228                tracing::warn!("agent '{name}' in TOML has no matching .md file, skipping");
229                continue;
230            };
231            let mut agent = agent_config.clone();
232            agent.name = CompactString::from(name.as_str());
233            agent.system_prompt = prompt.clone();
234            tracing::info!("registered agent '{name}' (thinking={})", agent.thinking);
235            runtime.add_agent(agent);
236        }
237
238        // Also register agents that have .md files but no TOML entry (defaults).
239        let default_think = config.system.walrus.thinking;
240        for (stem, prompt) in &prompt_map {
241            if config.agents.contains_key(stem) {
242                continue;
243            }
244            let mut agent = AgentConfig::new(stem.as_str());
245            agent.system_prompt = prompt.clone();
246            agent.thinking = default_think;
247            tracing::info!("registered agent '{stem}' (defaults, thinking={default_think})");
248            runtime.add_agent(agent);
249        }
250
251        // Populate per-agent scope maps for dispatch enforcement.
252        for agent_config in runtime.agents() {
253            runtime
254                .hook
255                .register_scope(agent_config.name.clone(), &agent_config);
256        }
257
258        Ok(())
259    }
260}
261
262/// Detect sandbox mode by checking if the current process is running as
263/// a user named `walrus`.
264fn detect_sandbox() -> bool {
265    std::env::var("USER")
266        .or_else(|_| std::env::var("LOGNAME"))
267        .is_ok_and(|u| u == "walrus")
268}