impl RoboticusConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)?;
Self::from_str(&contents)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(toml_str: &str) -> Result<Self> {
let mut config: Self = toml::from_str(toml_str)?;
config.normalize_paths();
config.migrate_mcp_clients();
config.merge_bundled_providers();
config.validate()?;
Ok(config)
}
pub fn normalize_paths(&mut self) {
self.database.path = expand_tilde(&self.database.path);
self.agent.workspace = expand_tilde(&self.agent.workspace);
self.server.log_dir = expand_tilde(&self.server.log_dir);
self.skills.skills_dir = expand_tilde(&self.skills.skills_dir);
self.wallet.path = expand_tilde(&self.wallet.path);
self.plugins.dir = expand_tilde(&self.plugins.dir);
self.browser.profile_dir = expand_tilde(&self.browser.profile_dir);
self.daemon.pid_file = expand_tilde(&self.daemon.pid_file);
self.multimodal.media_dir = self.multimodal.media_dir.as_ref().map(|p| expand_tilde(p));
self.devices.identity_path = self.devices.identity_path.as_ref().map(|p| expand_tilde(p));
if let Some(ref vp) = self.obsidian.vault_path {
self.obsidian.vault_path = Some(expand_tilde(vp));
}
self.obsidian.auto_detect_paths = self
.obsidian
.auto_detect_paths
.iter()
.map(|p| expand_tilde(p))
.collect();
for source in &mut self.knowledge.sources {
if let Some(ref p) = source.path {
source.path = Some(expand_tilde(p));
}
}
if self.obsidian.enabled
&& let Some(ref vp) = self.obsidian.vault_path
{
let canonical = vp.clone();
if !self.security.filesystem.tool_allowed_paths.contains(&canonical) {
self.security.filesystem.tool_allowed_paths.push(canonical);
}
}
for path in &self.security.filesystem.script_allowed_paths {
if !self.security.filesystem.tool_allowed_paths.contains(path) {
self.security.filesystem.tool_allowed_paths.push(path.clone());
}
}
}
fn migrate_mcp_clients(&mut self) {
if self.mcp.clients.is_empty() {
return;
}
let existing_names: std::collections::HashSet<String> =
self.mcp.servers.iter().map(|s| s.name.clone()).collect();
for client in &self.mcp.clients {
if existing_names.contains(&client.name) {
continue; }
let spec = match client.transport {
McpTransport::Sse | McpTransport::Http | McpTransport::WebSocket => {
McpServerSpec::Sse {
url: client.url.clone(),
}
}
McpTransport::Stdio => McpServerSpec::Stdio {
command: client.url.clone(),
args: vec![],
env: Default::default(),
},
};
self.mcp.servers.push(McpServerConfig {
name: client.name.clone(),
spec,
enabled: true,
auth_token_env: client.auth_token_env.clone(),
tool_allowlist: vec![],
});
}
tracing::warn!(
count = self.mcp.clients.len(),
"[mcp].clients is deprecated — entries have been migrated to [mcp].servers. \
Update your roboticus.toml to use [[mcp.servers]] instead of [[mcp.clients]]."
);
self.mcp.clients.clear();
}
fn merge_bundled_providers(&mut self) {
let bundled: BundledProviders = toml::from_str(BUNDLED_PROVIDERS_TOML)
.expect("bundled providers TOML must parse — this is a build-time error");
let disabled: std::collections::HashSet<String> = self
.disabled_bundled_providers
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
for (name, bundled_cfg) in bundled.providers {
if disabled.contains(&name.to_ascii_lowercase()) {
continue;
}
self.providers.entry(name).or_insert(bundled_cfg);
}
}
pub fn bundled_providers_toml() -> &'static str {
BUNDLED_PROVIDERS_TOML
}
pub fn validate(&self) -> Result<()> {
if self.models.primary.is_empty() {
return Err(RoboticusError::Config(
"models.primary must be non-empty".into(),
));
}
if self.agent.id.is_empty() {
return Err(RoboticusError::Config("agent.id must be non-empty".into()));
}
if self.agent.name.is_empty() {
return Err(RoboticusError::Config("agent.name must be non-empty".into()));
}
if self.agent.autonomy_max_react_turns == 0 {
return Err(RoboticusError::Config(
"agent.autonomy_max_react_turns must be >= 1".into(),
));
}
if self.agent.autonomy_max_turn_duration_seconds == 0 {
return Err(RoboticusError::Config(
"agent.autonomy_max_turn_duration_seconds must be >= 1".into(),
));
}
if !matches!(self.session.scope_mode.as_str(), "agent" | "peer" | "group") {
return Err(RoboticusError::Config(format!(
"session.scope_mode must be one of \"agent\", \"peer\", \"group\", got \"{}\"",
self.session.scope_mode
)));
}
let sum = self.memory.working_budget_pct
+ self.memory.episodic_budget_pct
+ self.memory.semantic_budget_pct
+ self.memory.procedural_budget_pct
+ self.memory.relationship_budget_pct;
if (sum - 100.0).abs() > 0.01 {
return Err(RoboticusError::Config(format!(
"memory budget percentages must sum to 100, got {sum}"
)));
}
if self.treasury.per_payment_cap <= 0.0 {
return Err(RoboticusError::Config(
"treasury.per_payment_cap must be positive".into(),
));
}
if self.treasury.minimum_reserve < 0.0 {
return Err(RoboticusError::Config(
"treasury.minimum_reserve must be non-negative".into(),
));
}
if !self.security.deny_on_empty_allowlist {
return Err(RoboticusError::Config(
"security.deny_on_empty_allowlist=false is no longer supported; run update or mechanic repair to migrate the config".into(),
));
}
if self.treasury.revenue_swap.target_symbol.trim().is_empty() {
return Err(RoboticusError::Config(
"treasury.revenue_swap.target_symbol must be non-empty".into(),
));
}
if self.treasury.revenue_swap.default_chain.trim().is_empty() {
return Err(RoboticusError::Config(
"treasury.revenue_swap.default_chain must be non-empty".into(),
));
}
let mut seen_revenue_swap_chains = std::collections::HashSet::new();
for chain in &self.treasury.revenue_swap.chains {
let normalized = chain.chain.trim().to_ascii_uppercase();
if normalized.is_empty() {
return Err(RoboticusError::Config(
"treasury.revenue_swap.chains[].chain must be non-empty".into(),
));
}
if chain.target_contract_address.trim().is_empty() {
return Err(RoboticusError::Config(format!(
"treasury.revenue_swap.chains[{normalized}].target_contract_address must be non-empty"
)));
}
if !seen_revenue_swap_chains.insert(normalized.clone()) {
return Err(RoboticusError::Config(format!(
"treasury.revenue_swap.chains contains duplicate chain '{normalized}'"
)));
}
}
if self.treasury.revenue_swap.enabled
&& !seen_revenue_swap_chains.contains(
&self
.treasury
.revenue_swap
.default_chain
.trim()
.to_ascii_uppercase(),
)
{
return Err(RoboticusError::Config(
"treasury.revenue_swap.default_chain must exist in treasury.revenue_swap.chains when enabled".into(),
));
}
if !(0.0..=1.0).contains(&self.self_funding.tax.rate) {
return Err(RoboticusError::Config(
"self_funding.tax.rate must be between 0.0 and 1.0".into(),
));
}
if self.self_funding.tax.enabled
&& self
.self_funding
.tax
.destination_wallet
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_none()
{
return Err(RoboticusError::Config(
"self_funding.tax.destination_wallet must be set when profit tax is enabled"
.into(),
));
}
if self.server.bind.parse::<std::net::IpAddr>().is_err() && self.server.bind != "localhost"
{
return Err(RoboticusError::Config(format!(
"server.bind '{}' is not a valid IP address",
self.server.bind
)));
}
if self.server.cron_max_concurrency == 0 || self.server.cron_max_concurrency > 16 {
return Err(RoboticusError::Config(format!(
"server.cron_max_concurrency must be between 1 and 16, got {}",
self.server.cron_max_concurrency
)));
}
if self.security.allowlist_authority > self.security.trusted_authority {
return Err(RoboticusError::Config(
"security.allowlist_authority must be ≤ security.trusted_authority \
(allow-list is a weaker signal than trusted_sender_ids)"
.into(),
));
}
if self.security.threat_caution_ceiling >= crate::types::InputAuthority::Creator {
return Err(RoboticusError::Config(
"security.threat_caution_ceiling must be below Creator \
(otherwise the threat scanner has no effect)"
.into(),
));
}
for p in &self.security.filesystem.script_allowed_paths {
if !p.is_absolute() {
return Err(RoboticusError::Config(format!(
"security.filesystem.script_allowed_paths: '{}' must be an absolute path",
p.display()
)));
}
}
if !matches!(self.models.routing.mode.as_str(), "primary" | "fallback" | "auto" | "routed" | "metascore") {
return Err(RoboticusError::Config(format!(
"models.routing.mode must be one of \"primary\", \"fallback\", or \"auto\", got \"{}\"",
self.models.routing.mode
)));
}
if !(0.0..=1.0).contains(&self.models.routing.confidence_threshold) {
return Err(RoboticusError::Config(format!(
"models.routing.confidence_threshold must be in [0.0, 1.0], got {}",
self.models.routing.confidence_threshold
)));
}
if self.models.routing.estimated_output_tokens == 0 {
return Err(RoboticusError::Config(
"models.routing.estimated_output_tokens must be >= 1".into(),
));
}
if !(0.0..=1.0).contains(&self.models.routing.accuracy_floor) {
return Err(RoboticusError::Config(format!(
"models.routing.accuracy_floor must be in [0.0, 1.0], got {}",
self.models.routing.accuracy_floor
)));
}
if self.models.routing.accuracy_min_obs == 0 {
return Err(RoboticusError::Config(
"models.routing.accuracy_min_obs must be >= 1".into(),
));
}
if let Some(cost_weight) = self.models.routing.cost_weight
&& !(0.0..=1.0).contains(&cost_weight)
{
return Err(RoboticusError::Config(format!(
"models.routing.cost_weight must be in [0.0, 1.0], got {cost_weight}"
)));
}
if !(0.0..=1.0).contains(&self.models.routing.canary_fraction) {
return Err(RoboticusError::Config(format!(
"models.routing.canary_fraction must be in [0.0, 1.0], got {}",
self.models.routing.canary_fraction
)));
}
let canary_model = self
.models
.routing
.canary_model
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty());
if self.models.routing.canary_fraction > 0.0 && canary_model.is_none() {
return Err(RoboticusError::Config(
"models.routing.canary_fraction > 0 requires models.routing.canary_model".into(),
));
}
if canary_model.is_some() && self.models.routing.canary_fraction <= 0.0 {
return Err(RoboticusError::Config(
"models.routing.canary_model requires models.routing.canary_fraction > 0".into(),
));
}
if let Some(canary) = canary_model
&& self
.models
.routing
.blocked_models
.iter()
.any(|m| m.trim() == canary)
{
return Err(RoboticusError::Config(
"models.routing.canary_model must not also appear in models.routing.blocked_models"
.into(),
));
}
for blocked in &self.models.routing.blocked_models {
if blocked.trim().is_empty() {
return Err(RoboticusError::Config(
"models.routing.blocked_models entries must be non-empty".into(),
));
}
}
if self.models.routing.per_provider_timeout_seconds < 5 {
return Err(RoboticusError::Config(format!(
"models.routing.per_provider_timeout_seconds must be >= 5, got {}",
self.models.routing.per_provider_timeout_seconds
)));
}
if self.models.routing.max_total_inference_seconds < self.models.routing.per_provider_timeout_seconds {
return Err(RoboticusError::Config(
"models.routing.max_total_inference_seconds must be >= per_provider_timeout_seconds".into(),
));
}
if self.models.routing.max_fallback_attempts == 0 {
return Err(RoboticusError::Config(
"models.routing.max_fallback_attempts must be >= 1".into(),
));
}
let min_useful_total = self
.models
.routing
.per_provider_timeout_seconds
.saturating_mul(self.models.routing.max_fallback_attempts as u64);
if self.models.routing.max_total_inference_seconds < min_useful_total {
tracing::warn!(
per_provider = self.models.routing.per_provider_timeout_seconds,
max_total = self.models.routing.max_total_inference_seconds,
max_attempts = self.models.routing.max_fallback_attempts,
"max_total_inference_seconds < per_provider_timeout_seconds * max_fallback_attempts; \
the fallback chain may be truncated by the total budget"
);
}
if let Some(ref sig) = self.channels.signal {
if !sig.phone_number.is_empty() && !sig.phone_number.starts_with('+') {
tracing::warn!(
phone_number = %sig.phone_number,
"channels.signal.phone_number should be in E.164 format (e.g. \"+15551234567\")"
);
}
for num in &sig.allowed_numbers {
if !num.starts_with('+') {
tracing::warn!(
number = %num,
"channels.signal.allowed_numbers entry should be in E.164 format \
with country code (e.g. \"+15551234567\"); exact match will fail \
if signal-cli reports the sender in E.164 format"
);
}
}
}
if let Some(ref wa) = self.channels.whatsapp {
for num in &wa.allowed_numbers {
if !num.starts_with('+') && num.chars().all(|c| c.is_ascii_digit()) {
tracing::warn!(
number = %num,
"channels.whatsapp.allowed_numbers entry looks like a bare number \
without country code; if WhatsApp reports senders in E.164 format \
(e.g. \"+15551234567\"), this entry won't match"
);
}
}
}
const MIN_HEARTBEAT_INTERVAL: u64 = 1;
let hb = &self.heartbeat;
for (name, val) in [
("treasury_interval_seconds", hb.treasury_interval_seconds),
("yield_interval_seconds", hb.yield_interval_seconds),
("memory_interval_seconds", hb.memory_interval_seconds),
("maintenance_interval_seconds", hb.maintenance_interval_seconds),
("session_interval_seconds", hb.session_interval_seconds),
("discovery_interval_seconds", hb.discovery_interval_seconds),
] {
if val < MIN_HEARTBEAT_INTERVAL {
return Err(RoboticusError::Config(format!(
"heartbeat.{name} must be >= {MIN_HEARTBEAT_INTERVAL}s, got {val}"
)));
}
}
Ok(())
}
}