Skip to main content

crabtalk_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    config::{ResolvedManifest, resolve_manifests},
11    daemon::event::{DaemonEvent, DaemonEventSender},
12    hook::{self, DaemonHook, skill::loader, system::memory::Memory},
13};
14use anyhow::Result;
15use model::ProviderRegistry;
16use std::{
17    collections::BTreeMap,
18    path::{Path, PathBuf},
19    sync::Arc,
20};
21use tokio::sync::RwLock;
22use wcore::{AgentConfig, Runtime, ToolRequest};
23
24/// Resolve qualified package references in an agent's skill list.
25///
26/// Entries containing `/` (e.g. `"crabtalk/gstack"`) are treated as package
27/// references. Each is expanded to the individual skill names found in that
28/// package's skill directory. Plain skill names are left as-is.
29fn resolve_package_skills(
30    skills: &mut Vec<String>,
31    package_skill_dirs: &BTreeMap<String, PathBuf>,
32) {
33    let mut resolved = Vec::new();
34    for entry in skills.drain(..) {
35        if entry.contains('/') {
36            if let Some(dir) = package_skill_dirs.get(&entry) {
37                match loader::load_skills_dir(dir) {
38                    Ok(registry) => {
39                        for skill in registry.skills() {
40                            resolved.push(skill.name.clone());
41                        }
42                    }
43                    Err(e) => {
44                        tracing::warn!("failed to resolve package skills for '{entry}': {e}");
45                    }
46                }
47            } else {
48                tracing::warn!("unknown package skill reference: '{entry}'");
49            }
50        } else {
51            resolved.push(entry);
52        }
53    }
54    *skills = resolved;
55}
56
57const SYSTEM_AGENT: &str = include_str!("../../prompts/crab.md");
58
59impl Daemon {
60    /// Build a fully-configured [`Daemon`] from the given config, config
61    /// directory, and event sender.
62    pub(crate) async fn build(
63        config: &DaemonConfig,
64        config_dir: &Path,
65        event_tx: DaemonEventSender,
66    ) -> Result<Self> {
67        let runtime = Self::build_runtime(config, config_dir, &event_tx).await?;
68        Ok(Self {
69            runtime: Arc::new(RwLock::new(Arc::new(runtime))),
70            config_dir: config_dir.to_path_buf(),
71            event_tx,
72        })
73    }
74
75    /// Rebuild the runtime from disk and swap it in atomically.
76    ///
77    /// In-flight requests that already hold a reference to the old runtime
78    /// complete normally. New requests after the swap see the new runtime.
79    pub async fn reload(&self) -> Result<()> {
80        let config = DaemonConfig::load(&self.config_dir.join(wcore::paths::CONFIG_FILE))?;
81        let new_runtime = Self::build_runtime(&config, &self.config_dir, &self.event_tx).await?;
82        *self.runtime.write().await = Arc::new(new_runtime);
83        tracing::info!("daemon reloaded");
84        Ok(())
85    }
86
87    /// Construct a fresh [`Runtime`] from config. Used by both [`build`] and [`reload`].
88    async fn build_runtime(
89        config: &DaemonConfig,
90        config_dir: &Path,
91        event_tx: &DaemonEventSender,
92    ) -> Result<Runtime<ProviderRegistry, DaemonHook>> {
93        let manager = Self::build_providers(config)?;
94        let (manifest, _warnings) = resolve_manifests(config_dir);
95        let hook = Self::build_hook(config, config_dir, &manifest, event_tx).await?;
96        let tool_tx = Self::build_tool_sender(event_tx);
97        let mut runtime = Runtime::new(manager, hook, Some(tool_tx)).await;
98        Self::load_agents(&mut runtime, config, &manifest)?;
99        Ok(runtime)
100    }
101
102    /// Construct the provider registry from config.
103    ///
104    /// Builds remote providers from config and sets the active model.
105    fn build_providers(config: &DaemonConfig) -> Result<ProviderRegistry> {
106        let active_model = config
107            .system
108            .crab
109            .model
110            .clone()
111            .ok_or_else(|| anyhow::anyhow!("system.crab.model is required in config.toml"))?;
112        let registry = ProviderRegistry::from_providers(active_model.clone(), &config.provider)?;
113
114        tracing::info!(
115            "provider registry initialized — active model: {}",
116            registry.active_model_name().unwrap_or_default()
117        );
118        Ok(registry)
119    }
120
121    /// Build the daemon hook with all backends (skills, MCP, tasks, memory).
122    async fn build_hook(
123        config: &DaemonConfig,
124        config_dir: &Path,
125        manifest: &ResolvedManifest,
126        event_tx: &DaemonEventSender,
127    ) -> Result<DaemonHook> {
128        let skills =
129            hook::skill::SkillHandler::load(manifest.skill_dirs.clone()).unwrap_or_else(|e| {
130                tracing::warn!("failed to load skills: {e}");
131                hook::skill::SkillHandler::default()
132            });
133
134        // Inject [env] from config.toml into each MCP's env map.
135        let mcp_servers: Vec<_> = manifest
136            .mcps
137            .values()
138            .map(|mcp| {
139                let mut mcp = mcp.clone();
140                for (k, v) in &config.env {
141                    mcp.env.entry(k.clone()).or_insert_with(|| v.clone());
142                }
143                mcp
144            })
145            .collect();
146        let mcp_handler = hook::mcp::McpHandler::load(&mcp_servers).await;
147
148        let memory = Some(Memory::open(
149            config_dir.join("memory"),
150            config.system.memory.clone(),
151            Box::new(crate::hook::system::memory::storage::FsStorage),
152        ));
153
154        let cwd = std::env::current_dir().unwrap_or_else(|_| config_dir.to_path_buf());
155
156        Ok(DaemonHook::new(
157            skills,
158            mcp_handler,
159            cwd,
160            memory,
161            event_tx.clone(),
162        ))
163    }
164
165    /// Build a [`ToolSender`] that forwards [`ToolRequest`]s into the daemon
166    /// event loop as [`DaemonEvent::ToolCall`] variants.
167    ///
168    /// Spawns a lightweight bridge task relaying from the tool channel into
169    /// the main daemon event channel.
170    fn build_tool_sender(event_tx: &DaemonEventSender) -> wcore::ToolSender {
171        let (tool_tx, mut tool_rx) = tokio::sync::mpsc::unbounded_channel::<ToolRequest>();
172        let event_tx = event_tx.clone();
173        tokio::spawn(async move {
174            while let Some(req) = tool_rx.recv().await {
175                if event_tx.send(DaemonEvent::ToolCall(req)).is_err() {
176                    break;
177                }
178            }
179        });
180        tool_tx
181    }
182
183    /// Load agents and add them to the runtime.
184    ///
185    /// The built-in crab agent is always registered first. Sub-agents are
186    /// loaded from manifest agent configs matched to `.md` prompt files
187    /// from the agent directories.
188    fn load_agents(
189        runtime: &mut Runtime<ProviderRegistry, DaemonHook>,
190        config: &DaemonConfig,
191        manifest: &ResolvedManifest,
192    ) -> Result<()> {
193        // Load prompt files from all agent directories.
194        let prompts = crate::config::load_agents_dirs(&manifest.agent_dirs)?;
195        let prompt_map: std::collections::BTreeMap<String, String> = prompts.into_iter().collect();
196
197        // Built-in crab agent. Read soul from memory (Crab.md), fall back to compiled-in.
198        let mut crab_config = config.system.crab.clone();
199        crab_config.name = wcore::paths::DEFAULT_AGENT.to_owned();
200        crab_config.system_prompt = runtime
201            .hook
202            .memory
203            .as_ref()
204            .map(|m| m.build_soul())
205            .unwrap_or_else(|| SYSTEM_AGENT.to_owned());
206        runtime.add_agent(crab_config);
207
208        // Sub-agents from manifests — each must have a matching .md file.
209        for (name, agent_config) in &manifest.agents {
210            if name == wcore::paths::DEFAULT_AGENT {
211                tracing::warn!(
212                    "agents.{name} overrides the built-in system agent and will be ignored — \
213                     configure it under [system.crab] instead"
214                );
215                continue;
216            }
217            let Some(prompt) = prompt_map.get(name) else {
218                tracing::warn!("agent '{name}' in manifest has no matching .md file, skipping");
219                continue;
220            };
221            let mut agent = agent_config.clone();
222            agent.name = name.clone();
223            agent.system_prompt = prompt.clone();
224            resolve_package_skills(&mut agent.skills, &manifest.package_skill_dirs);
225            tracing::info!("registered agent '{name}' (thinking={})", agent.thinking);
226            runtime.add_agent(agent);
227        }
228
229        // Also register agents that have .md files but no manifest entry (defaults).
230        let default_think = config.system.crab.thinking;
231        for (stem, prompt) in &prompt_map {
232            if stem == wcore::paths::DEFAULT_AGENT {
233                tracing::warn!(
234                    "agents/{stem}.md shadows the built-in system agent and will be ignored"
235                );
236                continue;
237            }
238            if manifest.agents.contains_key(stem) {
239                continue;
240            }
241            let mut agent = AgentConfig::new(stem.as_str());
242            agent.system_prompt = prompt.clone();
243            agent.thinking = default_think;
244            tracing::info!("registered agent '{stem}' (defaults, thinking={default_think})");
245            runtime.add_agent(agent);
246        }
247
248        // Populate per-agent scope maps for dispatch enforcement.
249        for agent_config in runtime.agents() {
250            runtime
251                .hook
252                .register_scope(agent_config.name.clone(), &agent_config);
253        }
254
255        Ok(())
256    }
257}