Skip to main content

robit_agent/
bootstrap.rs

1//! Bootstrap module — common setup for loading skills and creating tools.
2//!
3//! This module provides reusable functions for frontends (robit-tui, robit-gui, etc.)
4//! to avoid duplicating skill loading and tool creation code.
5
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use robit_ai::config::{
10    resolve_image_provider, resolve_memory_mode, resolve_profile, MemoryMode, RobitConfig,
11};
12
13use crate::image_gen::ImageGenClient;
14use crate::skill::{load_skills, Skill, SkillRegistry};
15use crate::tool::bash::BashTool;
16use crate::tool::edit::EditTool;
17use crate::tool::find::FindTool;
18use crate::tool::generate_image::GenerateImageTool;
19use crate::tool::grep::GrepTool;
20use crate::tool::load_skill::LoadSkillTool;
21use crate::tool::ls::LsTool;
22use crate::tool::memory::{ForgetTool, ListMemoriesTool, MemorizeTool, RecallTool};
23use crate::tool::query_task::QueryTaskTool;
24use crate::tool::read::ReadTool;
25use crate::tool::search_history::SearchHistoryTool;
26use crate::tool::write::WriteTool;
27use crate::tool::ToolRegistry;
28use crate::SkillLoadError;
29
30// ============================================================================
31// BootstrapResult
32// ============================================================================
33
34/// Result of bootstrapping skills and tools.
35pub struct BootstrapResult {
36    /// The skill registry, ready for use.
37    pub skill_registry: Arc<SkillRegistry>,
38    /// The tool registry, ready for use.
39    pub tool_registry: Arc<ToolRegistry>,
40    /// Total skills loaded (before filtering by enabled_skills).
41    pub total_skills_loaded: usize,
42    /// Any errors that occurred during skill loading (non-fatal).
43    pub skill_load_errors: Vec<SkillLoadError>,
44}
45
46// ============================================================================
47// Bootstrap functions
48// ============================================================================
49
50/// Bootstrap both skills and tools in one call.
51///
52/// This is the main entry point for frontends. It:
53/// 1. Loads skills from global and project directories
54/// 2. Filters skills by config.enabled_skills
55/// 3. Creates SkillRegistry
56/// 4. Creates ToolRegistry with all standard tools
57///
58/// Returns a BootstrapResult with both registries and metadata.
59pub fn bootstrap(
60    config: &RobitConfig,
61    working_dir: &PathBuf,
62    base_tool_names: &[&str],
63) -> BootstrapResult {
64    let (skills, skill_load_errors) = load_all_skills(working_dir);
65    let total_skills_loaded = skills.len();
66
67    let filtered_skills = filter_skills_by_config(skills, config);
68
69    let skill_registry = Arc::new(SkillRegistry::new(filtered_skills, base_tool_names));
70    let tool_registry = Arc::new(create_tools_from_config(config, Arc::clone(&skill_registry)));
71
72    BootstrapResult {
73        skill_registry,
74        tool_registry,
75        total_skills_loaded,
76        skill_load_errors,
77    }
78}
79
80/// Load skills from standard locations (global ~/.robit/skills and project .robit/skills).
81///
82/// Returns (loaded_skills, load_errors).
83pub fn load_all_skills(working_dir: &PathBuf) -> (Vec<Skill>, Vec<SkillLoadError>) {
84    let global_skills_dir = dirs::home_dir().map(|h| h.join(".robit/skills"));
85    let project_skills_dir = Some(working_dir.join(".robit/skills"));
86
87    load_skills(global_skills_dir, project_skills_dir)
88}
89
90/// Filter skills by the enabled_skills list in config, if present.
91pub fn filter_skills_by_config(skills: Vec<Skill>, config: &RobitConfig) -> Vec<Skill> {
92    let enabled_skills = config.app.as_ref().and_then(|a| a.enabled_skills.as_ref());
93
94    match enabled_skills {
95        Some(list) => skills
96            .into_iter()
97            .filter(|s| list.contains(&s.frontmatter.name))
98            .collect(),
99        None => skills,
100    }
101}
102
103/// Create a ToolRegistry with tools filtered by config.enabled_tools.
104///
105/// - If enabled_tools is not specified: all tools are registered
106/// - If enabled_tools is specified: only register tools in the list
107/// - `read` and `load_skill` are always registered (required for basic functionality)
108/// - Memory tools (`memorize`/`recall`/`forget`/`list_memories`) are registered
109///   only when `memory_mode = "tools"`; `file`/`off` modes skip them entirely.
110pub fn create_tools_from_config(
111    config: &RobitConfig,
112    skill_registry: Arc<SkillRegistry>,
113) -> ToolRegistry {
114    let mut tools = ToolRegistry::new();
115    let context_config = config.app.as_ref().and_then(|a| a.context.as_ref());
116    let max_lines = context_config.and_then(|c| c.max_output_lines).unwrap_or(500);
117    let max_bytes = context_config
118        .and_then(|c| c.max_output_bytes)
119        .unwrap_or(51200);
120
121    // Whether the configured default model supports image inputs. The `read`
122    // tool uses this to decide whether to encode image files and to advertise
123    // image support in its description.
124    let supports_images = resolve_profile(config, None)
125        .map(|m| m.supports_images)
126        .unwrap_or(false);
127
128    // Max dimension for images encoded into the context (downscaled + JPEG
129    // beyond that; 0 disables).
130    let max_image_dimension = context_config
131        .and_then(|c| c.max_image_dimension)
132        .unwrap_or(1024);
133
134    // Memory tools are only registered in "tools" mode. The default is
135    // "file" (file-based memory), where they are entirely absent from the
136    // ToolRegistry and thus invisible to the LLM.
137    let memory_mode = resolve_memory_mode(config);
138    let memory_tools_enabled = memory_mode == MemoryMode::Tools;
139
140    // Always register read, load_skill, history, and query_task tools
141    // (required for basic functionality / async task visibility)
142    tools.register(ReadTool::new(
143        max_lines,
144        max_bytes,
145        supports_images,
146        max_image_dimension,
147    ));
148    tools.register(LoadSkillTool::new(skill_registry));
149    if memory_tools_enabled {
150        tools.register(MemorizeTool::new());
151        tools.register(RecallTool::new());
152        tools.register(ForgetTool::new());
153        tools.register(ListMemoriesTool::new());
154    }
155    tools.register(SearchHistoryTool::new());
156    tools.register(QueryTaskTool::new());
157
158    // Try to build the image generation client. Returns None when no image
159    // providers are configured (the tool is simply not registered in that case).
160    let mut image_client = build_image_client(config);
161
162    // Get enabled tools from config
163    let enabled_tools = config.app.as_ref().and_then(|a| a.enabled_tools.as_ref());
164
165    match enabled_tools {
166        Some(list) => {
167            // Configured: only register specified tools (always available tools already registered)
168            for tool_name in list {
169                match tool_name.as_str() {
170                    "read" => {} // already registered
171                    "load_skill" => {} // already registered
172                    "memorize" | "recall" | "forget" | "list_memories" => {
173                        // Only effective in "tools" mode; skipped otherwise.
174                        if !memory_tools_enabled {
175                            tracing::warn!(
176                                "memory tool {} listed in enabled_tools but memory_mode \
177                                 != \"tools\", skipping",
178                                tool_name
179                            );
180                        }
181                    }
182                    "search_history" => {} // already registered
183                    "query_task" => {} // already registered
184                    "bash" => tools.register(BashTool::new(max_bytes)),
185                    "write" => tools.register(WriteTool::new()),
186                    "edit" => tools.register(EditTool::new()),
187                    "ls" => tools.register(LsTool::new()),
188                    "find" => tools.register(FindTool::new(max_bytes)),
189                    "grep" => tools.register(GrepTool::new(max_lines, max_bytes)),
190                    "generate_image" => match image_client.take() {
191                        Some(client) => tools.register(GenerateImageTool::new(client)),
192                        None => tracing::warn!(
193                            "generate_image listed in enabled_tools but no image_provider \
194                             is configured, skipping"
195                        ),
196                    },
197                    _ => tracing::warn!("Unknown tool in enabled_tools config: {}", tool_name),
198                }
199            }
200        }
201        None => {
202            // Not configured: register all remaining tools
203            tools.register(BashTool::new(max_bytes));
204            tools.register(WriteTool::new());
205            tools.register(EditTool::new());
206            tools.register(LsTool::new());
207            tools.register(FindTool::new(max_bytes));
208            tools.register(GrepTool::new(max_lines, max_bytes));
209            if let Some(client) = image_client.take() {
210                tools.register(GenerateImageTool::new(client));
211            }
212        }
213    }
214
215    // The file-memory mechanism instructs the agent (via the system prompt)
216    // to create/update memory files with write/edit. If those tools are not
217    // registered, the instructions are unactionable and memory silently
218    // never persists — warn loudly at startup.
219    if memory_mode == MemoryMode::File {
220        let missing = missing_file_memory_tools(&tools.tool_names());
221        if !missing.is_empty() {
222            tracing::warn!(
223                "memory_mode = \"file\" but tool(s) [{}] are not enabled; the agent \
224                 cannot persist memory files (the system prompt instructs it to use \
225                 write/edit). Enable them, or set memory_mode = \"off\" to hide the \
226                 mechanism.",
227                missing.join(", ")
228            );
229        }
230    }
231
232    tools
233}
234
235/// Tools the file-memory mechanism relies on to create/update memory files.
236const FILE_MEMORY_REQUIRED_TOOLS: [&str; 2] = ["write", "edit"];
237
238/// Required file-memory tools missing from `names` (empty when all present).
239/// Pure helper split out from `create_tools_from_config` for testability.
240fn missing_file_memory_tools(names: &[&str]) -> Vec<&'static str> {
241    FILE_MEMORY_REQUIRED_TOOLS
242        .into_iter()
243        .filter(|tool| !names.contains(tool))
244        .collect()
245}
246
247/// Build the image generation client from config.
248///
249/// Returns `None` (without warning) when image generation is not configured -
250/// either no image providers are defined, or `default_image_model` is absent.
251/// Both are normal "user doesn't need image generation" states. Returns `None`
252/// with a warning only when configuration is present but invalid (e.g. missing
253/// API key or invalid model reference).
254fn build_image_client(config: &RobitConfig) -> Option<ImageGenClient> {
255    if config.image_providers.is_empty() {
256        return None;
257    }
258    // default_image_model is required; if absent, image generation is
259    // considered disabled (no warning - this is a valid "not using it" state).
260    if config.default_image_model.is_none() {
261        return None;
262    }
263    match resolve_image_provider(config) {
264        Ok(provider) => {
265            tracing::info!(
266                "Image generation provider configured: {}/{} (protocol: {:?}, mode: {:?})",
267                provider.provider_name,
268                provider.model_id,
269                provider.protocol,
270                provider.mode
271            );
272            Some(ImageGenClient::new(provider))
273        }
274        Err(e) => {
275            tracing::warn!("Failed to resolve image generation provider: {}", e);
276            None
277        }
278    }
279}
280
281/// Log any skill load errors as warnings.
282///
283/// Convenience function for frontends to log errors without duplicating code.
284pub fn log_skill_errors(errors: &[SkillLoadError]) {
285    for err in errors {
286        tracing::warn!("Skill load error: {:?}", err);
287    }
288}
289
290// ============================================================================
291// Tests
292// ============================================================================
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use robit_ai::config::{AppConfig, MemoryMode, RobitConfig};
298    use std::collections::HashMap;
299
300    const MEMORY_TOOL_NAMES: [&str; 4] = ["memorize", "recall", "forget", "list_memories"];
301
302    fn config_with_memory_mode(mode: Option<MemoryMode>) -> RobitConfig {
303        RobitConfig {
304            default_model: None,
305            providers: HashMap::new(),
306            app: Some(AppConfig {
307                memory_mode: mode,
308                ..Default::default()
309            }),
310            channels: None,
311            default_image_model: None,
312            image_providers: HashMap::new(),
313        }
314    }
315
316    fn registry_for(config: &RobitConfig) -> ToolRegistry {
317        let skills = Arc::new(SkillRegistry::new(Vec::new(), &[]));
318        create_tools_from_config(config, skills)
319    }
320
321    #[test]
322    fn memory_tools_registered_in_tools_mode() {
323        let registry = registry_for(&config_with_memory_mode(Some(MemoryMode::Tools)));
324        let names = registry.tool_names();
325        for tool in MEMORY_TOOL_NAMES {
326            assert!(names.contains(&tool), "missing {}", tool);
327        }
328    }
329
330    #[test]
331    fn memory_tools_not_registered_in_file_mode() {
332        let registry = registry_for(&config_with_memory_mode(Some(MemoryMode::File)));
333        let names = registry.tool_names();
334        for tool in MEMORY_TOOL_NAMES {
335            assert!(!names.contains(&tool), "{} should not be registered", tool);
336        }
337        assert!(names.contains(&"read"));
338    }
339
340    #[test]
341    fn memory_tools_not_registered_by_default() {
342        // 未配置 memory_mode(老用户升级路径)→ 默认 file,不注册记忆工具
343        let registry = registry_for(&config_with_memory_mode(None));
344        let names = registry.tool_names();
345        for tool in MEMORY_TOOL_NAMES {
346            assert!(!names.contains(&tool));
347        }
348    }
349
350    #[test]
351    fn memory_tools_not_registered_in_off_mode() {
352        let registry = registry_for(&config_with_memory_mode(Some(MemoryMode::Off)));
353        for tool in MEMORY_TOOL_NAMES {
354            assert!(!registry.tool_names().contains(&tool));
355        }
356    }
357
358    #[test]
359    fn memory_tools_in_enabled_tools_list_ignored_in_file_mode() {
360        // enabled_tools 显式列出记忆工具但模式非 tools → 跳过注册(warn),不报错
361        let mut config = config_with_memory_mode(Some(MemoryMode::File));
362        config.app.as_mut().unwrap().enabled_tools =
363            Some(vec!["read".into(), "memorize".into(), "recall".into()]);
364        let registry = registry_for(&config);
365        let names = registry.tool_names();
366        assert!(names.contains(&"read"));
367        for tool in MEMORY_TOOL_NAMES {
368            assert!(!names.contains(&tool));
369        }
370    }
371
372    #[test]
373    fn missing_file_memory_tools_empty_when_all_present() {
374        assert!(missing_file_memory_tools(&["read", "write", "edit", "bash"]).is_empty());
375    }
376
377    #[test]
378    fn missing_file_memory_tools_reports_each_missing() {
379        assert_eq!(missing_file_memory_tools(&["read", "edit"]), vec!["write"]);
380        assert_eq!(missing_file_memory_tools(&["read", "write"]), vec!["edit"]);
381        assert_eq!(missing_file_memory_tools(&["read"]), vec!["write", "edit"]);
382    }
383}