use std::collections::HashSet;
use std::io::{self, BufRead};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use tracing::{info, warn};
use std::time::Duration;
use zeptoclaw::agent::{AgentLoop, ContextBuilder, RuntimeContext};
use zeptoclaw::auth::{self, AuthMethod};
use zeptoclaw::bus::MessageBus;
use zeptoclaw::config::templates::{AgentTemplate, TemplateRegistry};
use zeptoclaw::config::ProjectBackend;
use zeptoclaw::config::{Config, MemoryBackend, MemoryCitationsMode};
use zeptoclaw::cron::CronService;
use zeptoclaw::memory::factory::create_searcher_with_provider;
use zeptoclaw::providers::{
provider_config_by_name, resolve_runtime_providers, ClaudeProvider, FallbackProvider,
GeminiProvider, LLMProvider, OpenAIProvider, ProviderPlugin, RetryProvider,
RuntimeProviderSelection,
};
use zeptoclaw::runtime::{create_runtime, NativeRuntime};
use zeptoclaw::session::SessionManager;
use zeptoclaw::skills::registry::{ClawHubRegistry, SearchCache};
use zeptoclaw::skills::SkillsLoader;
use zeptoclaw::tools::cron::CronTool;
use zeptoclaw::tools::delegate::DelegateTool;
use zeptoclaw::tools::filesystem::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool};
use zeptoclaw::tools::shell::ShellTool;
use zeptoclaw::tools::spawn::SpawnTool;
use zeptoclaw::tools::{
DdgSearchTool, EchoTool, FindSkillsTool, GitTool, GoogleSheetsTool, HttpRequestTool,
InstallSkillTool, MemoryGetTool, MemorySearchTool, MessageTool, PdfReadTool, ProjectTool,
R8rTool, TranscribeTool, WebFetchTool, WebSearchTool, WhatsAppTool,
};
pub(crate) fn read_line() -> Result<String> {
let mut input = String::new();
io::stdin()
.lock()
.read_line(&mut input)
.with_context(|| "Failed to read input")?;
Ok(input.trim().to_string())
}
pub(crate) fn read_secret() -> Result<String> {
rpassword::read_password_from_bufread(&mut std::io::stdin().lock())
.with_context(|| "Failed to read secret input")
}
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(path)
}
pub(crate) fn memory_backend_label(backend: &MemoryBackend) -> &'static str {
match backend {
MemoryBackend::Disabled => "none",
MemoryBackend::Builtin => "builtin",
MemoryBackend::Bm25 => "bm25",
MemoryBackend::Embedding => "embedding",
MemoryBackend::Hnsw => "hnsw",
MemoryBackend::Tantivy => "tantivy",
MemoryBackend::Qmd => "qmd",
}
}
pub(crate) fn memory_citations_label(mode: &MemoryCitationsMode) -> &'static str {
match mode {
MemoryCitationsMode::Auto => "auto",
MemoryCitationsMode::On => "on",
MemoryCitationsMode::Off => "off",
}
}
pub(crate) fn skills_loader_from_config(config: &Config) -> SkillsLoader {
let workspace_dir = config
.skills
.workspace_dir
.as_deref()
.map(expand_tilde)
.unwrap_or_else(|| Config::dir().join("skills"));
SkillsLoader::new(workspace_dir, None)
}
pub(crate) fn load_template_registry() -> Result<TemplateRegistry> {
let mut registry = TemplateRegistry::new();
let template_dir = Config::dir().join("templates");
registry
.merge_from_dir(&template_dir)
.with_context(|| format!("Failed to load templates from {}", template_dir.display()))?;
Ok(registry)
}
pub(crate) fn resolve_template(name: &str) -> Result<AgentTemplate> {
let registry = load_template_registry()?;
if let Some(template) = registry.get(name) {
return Ok(template.clone());
}
let mut available = registry
.names()
.into_iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
available.sort();
anyhow::bail!(
"Template '{}' not found. Available templates: {}",
name,
available.join(", ")
);
}
fn provider_from_runtime_selection(
selection: &RuntimeProviderSelection,
configured_model: &str,
) -> Option<Box<dyn LLMProvider>> {
match selection.backend {
"anthropic" => {
if selection.credential.is_bearer() {
Some(Box::new(ClaudeProvider::with_credential(
selection.credential.clone(),
)))
} else {
Some(Box::new(ClaudeProvider::new(&selection.api_key)))
}
}
"openai" => {
if selection.name == "gemini" {
let model = if configured_model.is_empty() {
GeminiProvider::default_gemini_model()
} else {
configured_model
};
let api_key = if selection.credential.is_bearer() {
None
} else {
Some(selection.api_key.as_str())
};
let prefer_oauth = selection.credential.is_bearer();
return GeminiProvider::from_config(api_key, model, prefer_oauth)
.map(|p| Box::new(p) as Box<dyn LLMProvider>);
}
let provider = if let Some(base_url) = selection.api_base.as_deref() {
OpenAIProvider::with_base_url(&selection.api_key, base_url)
} else {
OpenAIProvider::new(&selection.api_key)
};
Some(Box::new(provider))
}
_ => None,
}
}
struct RuntimeProviderCandidate {
name: &'static str,
provider: Box<dyn LLMProvider>,
}
fn apply_fallback_preference(
candidates: &mut Vec<RuntimeProviderCandidate>,
preferred: Option<&str>,
) {
let Some(preferred) = preferred.map(str::trim).filter(|name| !name.is_empty()) else {
return;
};
if candidates.len() < 2 {
return;
}
if candidates[0].name.eq_ignore_ascii_case(preferred) {
warn!(
preferred_fallback = preferred,
primary = candidates[0].name,
"Preferred fallback provider is already primary; keeping registry order"
);
return;
}
let preferred_index = candidates
.iter()
.enumerate()
.skip(1)
.find_map(|(index, candidate)| {
candidate
.name
.eq_ignore_ascii_case(preferred)
.then_some(index)
});
if let Some(index) = preferred_index {
let preferred_candidate = candidates.remove(index);
candidates.insert(1, preferred_candidate);
} else {
warn!(
preferred_fallback = preferred,
"Preferred fallback provider is not configured or runtime-supported; keeping registry order"
);
}
}
fn build_runtime_provider_chain(
config: &Config,
) -> Option<(Box<dyn LLMProvider>, Vec<&'static str>)> {
let mut candidates: Vec<RuntimeProviderCandidate> = Vec::new();
let configured_model = &config.agents.defaults.model;
for selection in resolve_runtime_providers(config) {
if let Some(provider) = provider_from_runtime_selection(&selection, configured_model) {
candidates.push(RuntimeProviderCandidate {
name: selection.name,
provider,
});
} else {
warn!(
provider = selection.name,
backend = selection.backend,
"Skipping runtime provider with unsupported backend"
);
}
}
let mut candidates_iter = candidates.into_iter();
let first = candidates_iter.next()?;
if !config.providers.fallback.enabled {
return Some((first.provider, vec![first.name]));
}
let mut fallback_candidates: Vec<RuntimeProviderCandidate> = candidates_iter.collect();
if !fallback_candidates.is_empty() {
let mut ordered = Vec::with_capacity(1 + fallback_candidates.len());
ordered.push(first);
ordered.append(&mut fallback_candidates);
apply_fallback_preference(&mut ordered, config.providers.fallback.provider.as_deref());
let mut ordered_iter = ordered.into_iter();
let primary = ordered_iter.next()?;
let mut provider_names = vec![primary.name];
let mut provider_chain = primary.provider;
for candidate in ordered_iter {
provider_names.push(candidate.name);
provider_chain = Box::new(FallbackProvider::new(provider_chain, candidate.provider))
as Box<dyn LLMProvider>;
}
return Some((provider_chain, provider_names));
}
Some((first.provider, vec![first.name]))
}
fn apply_retry_wrapper(provider: Box<dyn LLMProvider>, config: &Config) -> Box<dyn LLMProvider> {
if !config.providers.retry.enabled {
return provider;
}
Box::new(
RetryProvider::new(provider)
.with_max_retries(config.providers.retry.max_retries)
.with_base_delay_ms(config.providers.retry.base_delay_ms)
.with_max_delay_ms(config.providers.retry.max_delay_ms)
.with_retry_budget_ms(config.providers.retry.retry_budget_ms),
)
}
fn provider_auth_method(config: &Config, name: &str) -> AuthMethod {
provider_config_by_name(config, name)
.map(|p| p.resolved_auth_method())
.unwrap_or_default()
}
async fn refresh_oauth_credentials_if_needed(config: &Config) {
let encryption = match zeptoclaw::security::encryption::resolve_master_key(false) {
Ok(enc) => enc,
Err(_) => return,
};
let store = auth::store::TokenStore::new(encryption);
for &provider in auth::oauth_supported_providers() {
let method = provider_auth_method(config, provider);
if !matches!(method, AuthMethod::OAuth | AuthMethod::Auto) {
continue;
}
let token = match store.load(provider) {
Ok(Some(token)) => token,
Ok(None) => continue,
Err(err) => {
warn!(provider = provider, error = %err, "Failed to load OAuth token from store");
continue;
}
};
if !token.expires_within(auth::refresh::REFRESH_BUFFER_SECS) {
continue;
}
if let Err(err) = auth::refresh::ensure_fresh_token(&store, provider).await {
warn!(provider = provider, error = %err, "Failed to refresh OAuth token");
}
}
}
fn build_skills_prompt(config: &Config) -> String {
if !config.skills.enabled {
return String::new();
}
let loader = skills_loader_from_config(config);
let disabled: std::collections::HashSet<String> = config
.skills
.disabled
.iter()
.map(|name| name.to_ascii_lowercase())
.collect();
let visible_skills = loader
.list_skills(false)
.into_iter()
.filter(|info| !disabled.contains(&info.name.to_ascii_lowercase()))
.collect::<Vec<_>>();
if visible_skills.is_empty() {
return String::new();
}
let mut summary_lines = vec!["<skills>".to_string()];
for info in &visible_skills {
if let Some(skill) = loader.load_skill(&info.name) {
let available = loader.check_requirements(&skill);
summary_lines.push(format!(" <skill available=\"{}\">", available));
summary_lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
summary_lines.push(format!(
" <description>{}</description>",
escape_xml(&skill.description)
));
summary_lines.push(format!(
" <location>{}</location>",
escape_xml(&skill.path)
));
summary_lines.push(" </skill>".to_string());
}
}
summary_lines.push("</skills>".to_string());
let mut always_names = loader.get_always_skills();
always_names.extend(config.skills.always_load.iter().cloned());
always_names.sort();
always_names.dedup();
always_names.retain(|name| !disabled.contains(&name.to_ascii_lowercase()));
always_names.retain(|name| loader.load_skill(name).is_some());
let always_content = if always_names.is_empty() {
String::new()
} else {
loader.load_skills_for_context(&always_names)
};
if always_content.is_empty() {
summary_lines.join("\n")
} else {
format!(
"{}\n\n## Active Skills\n\n{}",
summary_lines.join("\n"),
always_content
)
}
}
fn escape_xml(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub(crate) async fn create_agent(config: Config, bus: Arc<MessageBus>) -> Result<Arc<AgentLoop>> {
create_agent_with_template(config, bus, None).await
}
pub(crate) async fn create_agent_with_template(
mut config: Config,
bus: Arc<MessageBus>,
template: Option<AgentTemplate>,
) -> Result<Arc<AgentLoop>> {
if let Some(tpl) = &template {
if let Some(model) = &tpl.model {
config.agents.defaults.model = model.clone();
}
if let Some(max_tokens) = tpl.max_tokens {
config.agents.defaults.max_tokens = max_tokens;
}
if let Some(temperature) = tpl.temperature {
config.agents.defaults.temperature = temperature;
}
if let Some(max_tool_iterations) = tpl.max_tool_iterations {
config.agents.defaults.max_tool_iterations = max_tool_iterations;
}
}
let allowed_tools = template
.as_ref()
.and_then(|tpl| tpl.allowed_tools.as_ref())
.map(|names| {
names
.iter()
.map(|name| name.to_ascii_lowercase())
.collect::<HashSet<_>>()
});
let blocked_tools = template
.as_ref()
.and_then(|tpl| tpl.blocked_tools.as_ref())
.map(|names| {
names
.iter()
.map(|name| name.to_ascii_lowercase())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let profile_tools: Option<HashSet<String>> =
if let Some(ref profile_name) = config.agents.defaults.tool_profile {
match config.tool_profiles.get(profile_name) {
Some(tools) => tools
.as_ref()
.map(|names| names.iter().map(|n| n.to_ascii_lowercase()).collect()),
None => {
warn!(
"Tool profile '{}' not found in tool_profiles config — all tools enabled",
profile_name
);
None
}
}
} else {
None
};
let deny_tools: HashSet<String> = config
.tools
.deny
.iter()
.map(|n| n.to_ascii_lowercase())
.collect();
let tool_enabled = |name: &str| {
let key = name.to_ascii_lowercase();
if deny_tools.contains(&key) {
return false;
}
if let Some(ref profile) = profile_tools {
if !profile.contains(&key) {
return false;
}
}
if let Some(allowed) = &allowed_tools {
if !allowed.contains(&key) {
return false;
}
}
!blocked_tools.contains(&key)
};
let session_manager = SessionManager::new().unwrap_or_else(|_| {
warn!("Failed to create persistent session manager, using in-memory");
SessionManager::new_memory()
});
let skills_prompt = build_skills_prompt(&config);
let mut context_builder = ContextBuilder::new();
let soul_path = config.workspace_path().join("SOUL.md");
if soul_path.is_file() {
match std::fs::read_to_string(&soul_path) {
Ok(content) => {
let content = content.trim();
if !content.is_empty() {
context_builder = context_builder.with_soul(content);
info!("Loaded SOUL.md from {}", soul_path.display());
}
}
Err(e) => warn!("Failed to read SOUL.md at {}: {}", soul_path.display(), e),
}
}
if let Some(tpl) = &template {
context_builder = context_builder.with_system_prompt(&tpl.system_prompt);
}
if !skills_prompt.is_empty() {
context_builder = context_builder.with_skills(&skills_prompt);
}
let embedding_provider: Option<std::sync::Arc<dyn zeptoclaw::providers::LLMProvider>> =
if matches!(config.memory.backend, MemoryBackend::Embedding) {
build_runtime_provider_chain(&config).map(|(chain, _names)| std::sync::Arc::from(chain))
} else {
None
};
let memory_searcher = create_searcher_with_provider(&config.memory, embedding_provider);
if !matches!(config.memory.backend, MemoryBackend::Disabled) {
let ltm_path = zeptoclaw::config::Config::dir()
.join("memory")
.join("longterm.json");
match zeptoclaw::memory::longterm::LongTermMemory::with_path_and_searcher(
ltm_path,
memory_searcher.clone(),
) {
Ok(ltm) => {
let memory_ctx = zeptoclaw::memory::build_memory_injection(
<m,
"",
zeptoclaw::memory::MEMORY_INJECTION_BUDGET,
);
if !memory_ctx.is_empty() {
context_builder = context_builder.with_memory_context(memory_ctx);
info!("Injected pinned memories into system prompt");
}
}
Err(e) => warn!("Failed to load long-term memory for injection: {}", e),
}
}
let runtime_ctx = RuntimeContext::new()
.with_timezone(&config.agents.defaults.timezone)
.with_os_info();
context_builder = context_builder.with_runtime_context(runtime_ctx);
let agent = Arc::new(AgentLoop::with_context_builder(
config.clone(),
session_manager,
bus,
context_builder,
));
let cron_store_path = Config::dir().join("cron").join("jobs.json");
let cron_service = Arc::new(CronService::with_jitter(
cron_store_path,
agent.bus().clone(),
config.routines.jitter_ms,
));
cron_service.start(&config.routines.on_miss).await?;
let runtime = match create_runtime(&config.runtime).await {
Ok(r) => {
info!("Using {} runtime for shell commands", r.name());
r
}
Err(e) => {
if config.runtime.allow_fallback_to_native {
warn!(
"Failed to create configured runtime: {}. Falling back to native.",
e
);
Arc::new(NativeRuntime::new())
} else {
return Err(anyhow::anyhow!(
"Configured runtime '{:?}' unavailable: {}. \
Enable runtime.allow_fallback_to_native to opt in to native fallback.",
config.runtime.runtime_type,
e
));
}
}
};
if tool_enabled("echo") {
agent.register_tool(Box::new(EchoTool)).await;
}
if tool_enabled("read_file") {
agent.register_tool(Box::new(ReadFileTool)).await;
}
if tool_enabled("write_file") {
agent.register_tool(Box::new(WriteFileTool)).await;
}
if tool_enabled("list_dir") {
agent.register_tool(Box::new(ListDirTool)).await;
}
if tool_enabled("edit_file") {
agent.register_tool(Box::new(EditFileTool)).await;
}
if tool_enabled("shell") {
agent
.register_tool(Box::new(ShellTool::with_runtime(runtime)))
.await;
}
if tool_enabled("git") {
if GitTool::is_available() {
agent.register_tool(Box::new(GitTool::new())).await;
info!("Registered git tool");
} else {
tracing::debug!("git binary not found, skipping git tool");
}
}
if tool_enabled("web_search") {
let brave_key = config
.tools
.web
.search
.api_key
.as_deref()
.map(str::trim)
.filter(|k| !k.is_empty());
let max = config.tools.web.search.max_results as usize;
if let Some(key) = brave_key {
agent
.register_tool(Box::new(WebSearchTool::with_max_results(key, max)))
.await;
info!("Registered web_search tool (Brave)");
} else {
agent
.register_tool(Box::new(DdgSearchTool::with_max_results(max)))
.await;
info!("Registered web_search tool (DuckDuckGo fallback)");
}
}
if tool_enabled("web_fetch") {
agent.register_tool(Box::new(WebFetchTool::new())).await;
info!("Registered web_fetch tool");
}
if tool_enabled("http_request") {
if let Some(http_cfg) = &config.tools.http_request {
if !http_cfg.allowed_domains.is_empty() {
agent
.register_tool(Box::new(HttpRequestTool::new(
http_cfg.allowed_domains.clone(),
http_cfg.timeout_secs,
http_cfg.max_response_bytes,
)))
.await;
info!("Registered http_request tool");
}
}
}
if tool_enabled("pdf_read") {
let workspace_str = config.workspace_path().to_string_lossy().into_owned();
agent
.register_tool(Box::new(PdfReadTool::new(workspace_str)))
.await;
info!("Registered pdf_read tool");
}
if tool_enabled("message") {
agent
.register_tool(Box::new(MessageTool::new(agent.bus().clone())))
.await;
info!("Registered message tool");
}
if tool_enabled("whatsapp_send") {
if let (Some(phone_number_id), Some(access_token)) = (
config.tools.whatsapp.phone_number_id.as_deref(),
config.tools.whatsapp.access_token.as_deref(),
) {
if !phone_number_id.trim().is_empty() && !access_token.trim().is_empty() {
agent
.register_tool(Box::new(WhatsAppTool::with_default_language(
phone_number_id.trim(),
access_token.trim(),
config.tools.whatsapp.default_language.trim(),
)))
.await;
info!("Registered whatsapp_send tool");
}
}
}
if tool_enabled("google_sheets") {
if let Some(access_token) = config.tools.google_sheets.access_token.as_deref() {
let token = access_token.trim();
if !token.is_empty() {
agent
.register_tool(Box::new(GoogleSheetsTool::new(token)))
.await;
info!("Registered google_sheets tool");
}
} else if let Some(encoded) = config.tools.google_sheets.service_account_base64.as_deref() {
match GoogleSheetsTool::from_service_account(encoded.trim()) {
Ok(tool) => {
agent.register_tool(Box::new(tool)).await;
info!("Registered google_sheets tool from base64 payload");
}
Err(e) => warn!("Failed to initialize google_sheets tool: {}", e),
}
}
}
if !matches!(config.memory.backend, MemoryBackend::Disabled) {
if tool_enabled("memory_search") {
agent
.register_tool(Box::new(MemorySearchTool::with_searcher(
config.memory.clone(),
memory_searcher.clone(),
)))
.await;
}
if tool_enabled("memory_get") {
agent
.register_tool(Box::new(MemoryGetTool::new(config.memory.clone())))
.await;
}
if tool_enabled("longterm_memory") {
let ltm_path = zeptoclaw::config::Config::dir()
.join("memory")
.join("longterm.json");
match zeptoclaw::memory::longterm::LongTermMemory::with_path_and_searcher(
ltm_path,
memory_searcher.clone(),
) {
Ok(ltm) => {
let tool = zeptoclaw::tools::longterm_memory::LongTermMemoryTool::with_memory(
std::sync::Arc::new(tokio::sync::Mutex::new(ltm)),
);
agent.register_tool(Box::new(tool)).await;
info!(
"Registered longterm_memory tool (searcher: {})",
memory_searcher.name()
);
}
Err(e) => warn!("Failed to initialize longterm_memory tool: {}", e),
}
}
info!(
"Registered memory tools (backend: {})",
memory_searcher.name()
);
} else {
info!("Memory tools are disabled");
}
if tool_enabled("cron") {
agent
.register_tool(Box::new(CronTool::new(cron_service.clone())))
.await;
}
if tool_enabled("spawn") {
agent
.register_tool(Box::new(SpawnTool::new(
Arc::downgrade(&agent),
agent.bus().clone(),
)))
.await;
}
if tool_enabled("r8r") {
agent.register_tool(Box::new(R8rTool::default())).await;
}
if tool_enabled("project") {
let project_config = config.project.clone();
let has_token = match project_config.backend {
ProjectBackend::Github => project_config
.github_token
.as_deref()
.filter(|t| !t.is_empty())
.is_some(),
ProjectBackend::Jira => project_config
.jira_token
.as_deref()
.filter(|t| !t.is_empty())
.is_some(),
ProjectBackend::Linear => project_config
.linear_api_key
.as_deref()
.filter(|k| !k.is_empty())
.is_some(),
};
if has_token {
agent
.register_tool(Box::new(ProjectTool::new(project_config)))
.await;
info!(
"Registered project tool ({:?} backend)",
config.project.backend
);
}
}
if config.tools.transcribe.enabled {
if let Some(api_key) = &config.tools.transcribe.groq_api_key {
if tool_enabled("transcribe") {
match TranscribeTool::new(api_key, &config.tools.transcribe.model) {
Ok(tool) => {
agent.register_tool(Box::new(tool)).await;
info!(
"Registered transcribe tool (model: {})",
config.tools.transcribe.model
);
}
Err(e) => warn!("Failed to initialize transcribe tool: {}", e),
}
}
}
}
if tool_enabled("reminder") {
match zeptoclaw::tools::reminder::ReminderTool::new(Some(cron_service.clone())) {
Ok(tool) => {
agent.register_tool(Box::new(tool)).await;
info!("Registered reminder tool");
}
Err(e) => warn!("Failed to initialize reminder tool: {}", e),
}
}
if config.tools.skills.enabled && config.tools.skills.clawhub.enabled {
let cache = Arc::new(SearchCache::new(
config.tools.skills.search_cache.max_size,
Duration::from_secs(config.tools.skills.search_cache.ttl_seconds),
));
let registry = Arc::new(ClawHubRegistry::new(
&config.tools.skills.clawhub.base_url,
config.tools.skills.clawhub.auth_token.clone(),
cache,
));
if tool_enabled("find_skills") {
agent
.register_tool(Box::new(FindSkillsTool::new(Arc::clone(®istry))))
.await;
info!("Registered find_skills tool");
}
if tool_enabled("install_skill") {
let skills_dir = config
.skills
.workspace_dir
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| {
zeptoclaw::config::Config::dir()
.join("skills")
.to_string_lossy()
.into_owned()
});
agent
.register_tool(Box::new(InstallSkillTool::new(
Arc::clone(®istry),
skills_dir,
)))
.await;
info!("Registered install_skill tool");
}
}
#[cfg(feature = "android")]
if tool_enabled("android") {
agent
.register_tool(Box::new(zeptoclaw::tools::android::AndroidTool::new()))
.await;
info!("Registered android tool");
}
if config.plugins.enabled {
let plugin_dirs: Vec<PathBuf> = config
.plugins
.plugin_dirs
.iter()
.map(|d| expand_tilde(d))
.collect();
match zeptoclaw::plugins::discover_plugins(&plugin_dirs) {
Ok(plugins) => {
for plugin in plugins {
if !config.plugins.is_plugin_permitted(plugin.name()) {
info!(plugin = %plugin.name(), "Plugin blocked by config");
continue;
}
for tool_def in &plugin.manifest.tools {
if !tool_enabled(&tool_def.name) {
continue;
}
if plugin.manifest.is_binary() {
if let Some(ref bin_cfg) = plugin.manifest.binary {
match zeptoclaw::plugins::validate_binary_path(
&plugin.path,
bin_cfg,
) {
Ok(bin_path) => {
let timeout = bin_cfg
.timeout_secs
.unwrap_or_else(|| tool_def.effective_timeout());
agent
.register_tool(Box::new(
zeptoclaw::tools::binary_plugin::BinaryPluginTool::new(
tool_def.clone(),
plugin.name(),
bin_path,
timeout,
),
))
.await;
info!(
plugin = %plugin.name(),
tool = %tool_def.name,
"Registered binary plugin tool"
);
}
Err(e) => warn!(
plugin = %plugin.name(),
error = %e,
"Binary validation failed"
),
}
}
} else {
agent
.register_tool(Box::new(zeptoclaw::tools::plugin::PluginTool::new(
tool_def.clone(),
plugin.name(),
)))
.await;
info!(
plugin = %plugin.name(),
tool = %tool_def.name,
"Registered command plugin tool"
);
}
}
}
}
Err(e) => warn!(error = %e, "Plugin discovery failed"),
}
}
if tool_enabled("create_tool") {
agent
.register_tool(Box::new(zeptoclaw::tools::composed::CreateToolTool::new()))
.await;
}
for tool in zeptoclaw::tools::composed::load_composed_tools() {
let name = tool.name().to_string();
if !tool_enabled(&name) {
continue;
}
agent.register_tool(tool).await;
info!(tool = %name, "Registered composed tool");
}
let tool_warnings = zeptoclaw::config::validate::validate_custom_tools(&config);
for w in &tool_warnings {
warn!("Custom tool config: {}", w);
}
for tool_def in &config.custom_tools {
if !tool_enabled(&tool_def.name) {
continue;
}
if tool_def.command.trim().is_empty() {
warn!(tool = %tool_def.name, "Skipping custom tool with empty command");
continue;
}
let tool = zeptoclaw::tools::custom::CustomTool::new(tool_def.clone());
agent.register_tool(Box::new(tool)).await;
info!(tool = %tool_def.name, "Registered custom CLI tool");
}
info!("Registered {} tools", agent.tool_count().await);
refresh_oauth_credentials_if_needed(&config).await;
if let Some((provider_chain, provider_names)) = build_runtime_provider_chain(&config) {
let chain_label = provider_names.join(" -> ");
let provider_count = provider_names.len();
let retry_enabled = config.providers.retry.enabled;
let retry_max_retries = config.providers.retry.max_retries;
let retry_base_delay_ms = config.providers.retry.base_delay_ms;
let retry_max_delay_ms = config.providers.retry.max_delay_ms;
let provider_chain = apply_retry_wrapper(provider_chain, &config);
agent.set_provider(provider_chain).await;
if retry_enabled {
info!(
max_retries = retry_max_retries,
base_delay_ms = retry_base_delay_ms,
max_delay_ms = retry_max_delay_ms,
"Configured runtime provider retry wrapper"
);
}
if provider_count > 1 {
info!(
provider_count = provider_count,
provider_chain = %chain_label,
"Configured runtime provider fallback chain"
);
} else {
info!("Configured runtime provider: {}", chain_label);
}
}
for selection in resolve_runtime_providers(&config) {
if let Some(provider) =
provider_from_runtime_selection(&selection, &config.agents.defaults.model)
{
agent
.set_provider_in_registry(selection.name, provider)
.await;
info!(
provider = selection.name,
"Registered provider in model-switch registry"
);
}
}
if agent.provider().await.is_none() && !config.providers.plugins.is_empty() {
let mut plugin_iter = config.providers.plugins.iter();
if let Some(first_cfg) = plugin_iter.next() {
let first = ProviderPlugin::new(
first_cfg.name.clone(),
first_cfg.command.clone(),
first_cfg.args.clone(),
);
let mut chain: Box<dyn LLMProvider> = Box::new(first);
let mut chain_names = vec![first_cfg.name.clone()];
if config.providers.fallback.enabled {
for plugin_cfg in plugin_iter {
let fallback = ProviderPlugin::new(
plugin_cfg.name.clone(),
plugin_cfg.command.clone(),
plugin_cfg.args.clone(),
);
chain = Box::new(FallbackProvider::new(chain, Box::new(fallback)));
chain_names.push(plugin_cfg.name.clone());
}
}
let chain_label = chain_names.join(" -> ");
let plugin_count = chain_names.len();
let chain = apply_retry_wrapper(chain, &config);
agent.set_provider(chain).await;
if plugin_count > 1 {
info!(
plugin_count = plugin_count,
plugin_chain = %chain_label,
"Configured provider plugin fallback chain"
);
} else {
info!("Configured provider plugin: {}", chain_label);
}
}
}
let unsupported = zeptoclaw::providers::configured_unsupported_provider_names(&config);
if !unsupported.is_empty() {
warn!(
"Configured provider(s) not yet supported by runtime: {}",
unsupported.join(", ")
);
}
if tool_enabled("delegate") && config.swarm.enabled {
if let Some(provider) = agent.provider().await {
agent
.register_tool(Box::new(DelegateTool::new(
config.clone(),
provider,
agent.bus().clone(),
)))
.await;
info!("Registered delegate tool (swarm)");
} else {
warn!("Swarm enabled but no provider configured — delegate tool not registered");
}
}
Ok(agent)
}
pub(crate) async fn validate_api_key(
provider: &str,
api_key: &str,
api_base: Option<&str>,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
match provider {
"anthropic" => {
let base = api_base.unwrap_or("https://api.anthropic.com");
let resp = client
.get(format!("{}/v1/models", base))
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(anyhow::anyhow!(friendly_api_error(
"anthropic",
status,
&body
)))
}
}
"openai" => {
let base = api_base.unwrap_or("https://api.openai.com/v1");
let resp = client
.get(format!("{}/models", base))
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(anyhow::anyhow!(friendly_api_error("openai", status, &body)))
}
}
"openrouter" => {
let base = api_base.unwrap_or("https://openrouter.ai/api/v1");
let resp = client
.get(format!("{}/key", base))
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(anyhow::anyhow!(friendly_api_error(
"openrouter",
status,
&body
)))
}
}
_ => {
warn!(
"API key validation not supported for provider '{}', skipping",
provider
);
Ok(())
}
}
}
pub(crate) fn friendly_api_error(provider: &str, status: u16, body: &str) -> String {
let api_msg = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|e| e.get("message").or_else(|| e.as_str().map(|_| e)))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
});
let base = match status {
401 => format!(
"Invalid API key. Check your {} key and try again.\n {}",
provider,
match provider {
"anthropic" => "Get key: https://console.anthropic.com/",
"openrouter" => "Get key: https://openrouter.ai/settings/keys",
_ => "Get key: https://platform.openai.com/api-keys",
}
),
402 => match provider {
"openrouter" => {
"Insufficient OpenRouter credits. Add credits and try again.\n Credits: https://openrouter.ai/settings/credits"
.to_string()
}
_ => format!(
"Billing issue on your {} account. Add a payment method.\n {}",
provider,
match provider {
"anthropic" => "Billing: https://console.anthropic.com/settings/billing",
_ => "Billing: https://platform.openai.com/settings/organization/billing",
}
),
},
429 => "Rate limited. Wait a moment and try again.".to_string(),
404 => {
"Model not found. Your API key may not have access to the default model.".to_string()
}
_ => format!(
"API returned HTTP {}. Check your API key and account status.",
status
),
};
if let Some(msg) = api_msg {
format!("{}\n Detail: {}", base, msg)
} else {
base
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
atomic::{AtomicU32, Ordering},
Arc,
};
use async_trait::async_trait;
use zeptoclaw::error::ProviderError;
use zeptoclaw::providers::{ChatOptions, LLMResponse, ToolDefinition};
use zeptoclaw::session::Message;
#[derive(Debug)]
struct FlakyProvider {
calls: Arc<AtomicU32>,
fail_until: u32,
}
#[async_trait]
impl LLMProvider for FlakyProvider {
async fn chat(
&self,
_messages: Vec<Message>,
_tools: Vec<ToolDefinition>,
_model: Option<&str>,
_options: ChatOptions,
) -> zeptoclaw::error::Result<LLMResponse> {
let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
if call <= self.fail_until {
Err(ProviderError::RateLimit("simulated rate limit".to_string()).into())
} else {
Ok(LLMResponse::text("ok"))
}
}
fn default_model(&self) -> &str {
"mock-model"
}
fn name(&self) -> &str {
"mock"
}
}
#[test]
fn test_friendly_api_error_401_anthropic() {
let msg = friendly_api_error("anthropic", 401, "");
assert!(msg.contains("Invalid API key"));
assert!(msg.contains("anthropic"));
assert!(msg.contains("console.anthropic.com"));
}
#[test]
fn test_friendly_api_error_401_openai() {
let msg = friendly_api_error("openai", 401, "");
assert!(msg.contains("Invalid API key"));
assert!(msg.contains("openai"));
assert!(msg.contains("platform.openai.com"));
}
#[test]
fn test_friendly_api_error_401_openrouter() {
let msg = friendly_api_error("openrouter", 401, "");
assert!(msg.contains("Invalid API key"));
assert!(msg.contains("openrouter"));
assert!(msg.contains("openrouter.ai/settings/keys"));
}
#[test]
fn test_friendly_api_error_402() {
let msg = friendly_api_error("anthropic", 402, "");
assert!(msg.contains("Billing issue"));
}
#[test]
fn test_friendly_api_error_402_openrouter() {
let msg = friendly_api_error("openrouter", 402, "");
assert!(msg.contains("Insufficient OpenRouter credits"));
assert!(msg.contains("openrouter.ai/settings/credits"));
}
#[test]
fn test_friendly_api_error_unknown_status() {
let msg = friendly_api_error("openai", 500, "");
assert!(msg.contains("HTTP 500"));
}
#[test]
fn test_build_runtime_provider_chain_empty_when_no_provider() {
let config = Config::default();
assert!(build_runtime_provider_chain(&config).is_none());
}
#[test]
fn test_build_runtime_provider_chain_single_provider() {
let mut config = Config::default();
config.providers.openai = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
let (provider, names) =
build_runtime_provider_chain(&config).expect("provider chain should resolve");
assert_eq!(names, vec!["openai"]);
assert_eq!(provider.name(), "openai");
}
#[test]
fn test_build_runtime_provider_chain_preserves_registry_order() {
let mut config = Config::default();
config.providers.fallback.enabled = true;
config.providers.anthropic = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
config.providers.openai = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
config.providers.groq = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("gsk-test".to_string()),
..Default::default()
});
let (provider, names) =
build_runtime_provider_chain(&config).expect("provider chain should resolve");
assert_eq!(names, vec!["anthropic", "openai", "groq"]);
let chain_name = provider.name();
assert_eq!(chain_name.matches("->").count(), 2);
assert!(chain_name.contains("openai"));
}
#[test]
fn test_build_runtime_provider_chain_honors_preferred_fallback_provider() {
let mut config = Config::default();
config.providers.fallback.enabled = true;
config.providers.fallback.provider = Some("groq".to_string());
config.providers.anthropic = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
config.providers.openai = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
config.providers.groq = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("gsk-test".to_string()),
..Default::default()
});
let (_provider, names) =
build_runtime_provider_chain(&config).expect("provider chain should resolve");
assert_eq!(names, vec!["anthropic", "groq", "openai"]);
}
#[test]
fn test_build_runtime_provider_chain_no_chain_when_fallback_disabled() {
let mut config = Config::default();
config.providers.fallback.enabled = false;
config.providers.anthropic = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
config.providers.openai = Some(zeptoclaw::config::ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
let (provider, names) =
build_runtime_provider_chain(&config).expect("provider chain should resolve");
assert_eq!(names, vec!["anthropic"]);
assert_eq!(provider.name(), "claude");
}
#[tokio::test]
async fn test_apply_retry_wrapper_retries_when_enabled() {
let mut config = Config::default();
config.providers.retry.enabled = true;
config.providers.retry.max_retries = 3;
config.providers.retry.base_delay_ms = 0;
config.providers.retry.max_delay_ms = 0;
let calls = Arc::new(AtomicU32::new(0));
let wrapped = apply_retry_wrapper(
Box::new(FlakyProvider {
calls: Arc::clone(&calls),
fail_until: 2,
}),
&config,
);
let result = wrapped
.chat(
vec![Message::user("hello")],
vec![],
None,
ChatOptions::new(),
)
.await
.expect("retry wrapper should eventually succeed");
assert_eq!(result.content, "ok");
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_apply_retry_wrapper_is_noop_when_disabled() {
let mut config = Config::default();
config.providers.retry.enabled = false;
let calls = Arc::new(AtomicU32::new(0));
let wrapped = apply_retry_wrapper(
Box::new(FlakyProvider {
calls: Arc::clone(&calls),
fail_until: 1,
}),
&config,
);
let err = wrapped
.chat(
vec![Message::user("hello")],
vec![],
None,
ChatOptions::new(),
)
.await
.expect_err("retry disabled should not retry");
assert!(err.to_string().contains("rate limit"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}