//! Assembling the tool set. //! //! `ToolRegistry::from_config` only knows the ones that depend on nothing //! (the clock, the shell), because it lives in the core. The ones that need //! the network or the multimodal model are added here, which is exactly the //! extension point documented in docs/EXTENDER.md. use std::sync::Arc; use std::time::Duration; use asist_core::config::{self as cfg, Config, SearchConfig}; use asist_core::tools::ToolRegistry; use asist_llm::LlmClient; use asist_tools::{ Camera, CameraConfig, Screen, ScreenConfig, SearchBackend, VisionTool, WebSearch, }; /// What could not be enabled and why, to tell the user at startup instead /// of staying silent. pub struct Skipped { pub tool: &'static str, pub reason: String, } pub fn build(config: &Config, llm: &Arc) -> (ToolRegistry, Vec) { let mut registry = ToolRegistry::from_config(&config.tools); let mut skipped = Vec::new(); if !config.tools.enabled { return (registry, skipped); } match search_tool(&config.search) { Ok(Some(tool)) => registry.register(Arc::new(tool)), Ok(None) => {} Err(reason) => skipped.push(Skipped { tool: "buscar_en_internet", reason, }), } // The two that see share a shape, so they are registered the same way. for (result, tool) in [ (camera_tool(&config.camera, llm), "mirar_por_la_camara"), (screen_tool(&config.screen, llm), "mirar_la_pantalla"), ] { match result { Ok(Some(vision)) => registry.register(Arc::new(vision)), Ok(None) => {} Err(reason) => skipped.push(Skipped { tool, reason }), } } (registry, skipped) } fn search_tool(config: &SearchConfig) -> Result, String> { if !config.enabled { return Ok(None); } let backend = match config.backend.trim().to_lowercase().as_str() { "tavily" => { // The key comes from the environment, never from the configuration file. let key = std::env::var(&config.api_key_env) .ok() .map(|k| k.trim().to_string()) .filter(|k| !k.is_empty()) .ok_or_else(|| { format!( "the variable {} is empty or unset; export it or change \ search.backend to «searxng»", config.api_key_env ) })?; SearchBackend::Tavily { api_key: key } } "searxng" => SearchBackend::SearxNG { base_url: config.base_url.clone(), }, "ddgs" | "command" | "comando" => { let (program, args) = config .command .split_first() .ok_or_else(|| "search.command is empty".to_string())?; if !std::path::Path::new(program).exists() && !program.contains('/') { // A bare name is looked up in PATH; a path must exist. } else if !std::path::Path::new(program).exists() { return Err(format!("the search program {program} does not exist")); } SearchBackend::Command { program: program.into(), args: args.to_vec(), } } other => return Err(format!("search.backend desconocido: «{other}»")), }; Ok(Some(WebSearch::new( backend, config.max_results, Duration::from_secs(config.timeout_secs), ))) } fn camera_tool( config: &cfg::CameraConfig, llm: &Arc, ) -> Result, String> { if !config.enabled { return Ok(None); } let source = Camera::new(CameraConfig { device: config.device.clone(), width: config.width, height: config.height, warmup_frames: config.warmup_frames, timeout: Duration::from_secs(config.timeout_secs), save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), }); finish(VisionTool::camera(Box::new(source), Arc::clone(llm))) } fn screen_tool( config: &cfg::ScreenConfig, llm: &Arc, ) -> Result, String> { if !config.enabled { return Ok(None); } let source = Screen::new(ScreenConfig { command: config.command.clone(), width: config.width, output: config.output.clone(), timeout: Duration::from_secs(config.timeout_secs), save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), }); finish(VisionTool::screen(Box::new(source), Arc::clone(llm))) } /// Registering a tool that will always fail is worse than not having it: /// the model would call it, swallow the error and waste a whole turn. fn finish(tool: VisionTool) -> Result, String> { match tool.available() { Ok(()) => Ok(Some(tool)), Err(err) => Err(err.to_string()), } }