#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CompositionPolicy {
Autonomous,
#[default]
Propose,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillCreationRigor {
Generate,
Validate,
#[default]
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OutputValidationPolicy {
#[default]
Strict,
Sample,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub name: String,
pub id: String,
#[serde(default = "default_workspace")]
pub workspace: PathBuf,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_true")]
pub delegation_enabled: bool,
#[serde(default = "default_min_decomposition_complexity")]
pub delegation_min_complexity: f64,
#[serde(default = "default_min_delegation_utility_margin")]
pub delegation_min_utility_margin: f64,
#[serde(default = "default_true")]
pub specialist_creation_requires_approval: bool,
#[serde(default = "default_autonomy_max_react_turns")]
pub autonomy_max_react_turns: usize,
#[serde(default = "default_autonomy_max_turn_duration_seconds")]
pub autonomy_max_turn_duration_seconds: u64,
#[serde(default)]
pub composition_policy: CompositionPolicy,
#[serde(default)]
pub skill_creation_rigor: SkillCreationRigor,
#[serde(default)]
pub output_validation_policy: OutputValidationPolicy,
#[serde(default = "default_output_validation_sample_rate")]
pub output_validation_sample_rate: f64,
#[serde(default = "default_max_output_retries")]
pub max_output_retries: u32,
#[serde(default = "default_retirement_threshold")]
pub retirement_success_threshold: f64,
#[serde(default = "default_retirement_min_delegations")]
pub retirement_min_delegations: i64,
}
fn default_workspace() -> PathBuf {
dirs_next().join("workspace")
}
fn default_log_level() -> String {
"info".into()
}
fn default_min_decomposition_complexity() -> f64 {
0.35
}
fn default_min_delegation_utility_margin() -> f64 {
0.15
}
fn default_autonomy_max_react_turns() -> usize {
10
}
fn default_autonomy_max_turn_duration_seconds() -> u64 {
90
}
fn default_output_validation_sample_rate() -> f64 {
0.25
}
fn default_max_output_retries() -> u32 {
2
}
fn default_retirement_threshold() -> f64 {
0.3
}
fn default_retirement_min_delegations() -> i64 {
5
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
name: String::new(),
id: String::new(),
workspace: default_workspace(),
log_level: default_log_level(),
delegation_enabled: true,
delegation_min_complexity: default_min_decomposition_complexity(),
delegation_min_utility_margin: default_min_delegation_utility_margin(),
specialist_creation_requires_approval: true,
autonomy_max_react_turns: default_autonomy_max_react_turns(),
autonomy_max_turn_duration_seconds: default_autonomy_max_turn_duration_seconds(),
composition_policy: CompositionPolicy::default(),
skill_creation_rigor: SkillCreationRigor::default(),
output_validation_policy: OutputValidationPolicy::default(),
output_validation_sample_rate: default_output_validation_sample_rate(),
max_output_retries: default_max_output_retries(),
retirement_success_threshold: default_retirement_threshold(),
retirement_min_delegations: default_retirement_min_delegations(),
}
}
}
fn default_log_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".roboticus").join("logs")
}
fn default_log_max_days() -> u32 {
7
}
fn dirs_next() -> PathBuf {
let home = home_dir();
let new_dir = home.join(".roboticus");
migrate_legacy_data_dir(&home, &new_dir);
new_dir
}
fn migrate_legacy_data_dir(home: &Path, new_dir: &Path) {
use std::sync::Once;
static MIGRATE_ONCE: Once = Once::new();
MIGRATE_ONCE.call_once(|| {
let legacy = home.join(".ironclad");
let sentinel = new_dir.join(".migration_pending_delete");
if sentinel.exists()
&& let Ok(source) = std::fs::read_to_string(&sentinel)
{
let source_path = Path::new(source.trim());
if source_path.exists() {
match std::fs::remove_dir_all(source_path) {
Ok(()) => {
eprintln!("[roboticus] Completed deferred cleanup of {}", source_path.display());
let _ = std::fs::remove_file(&sentinel);
}
Err(e) => {
eprintln!(
"[roboticus] Still cannot remove {}: {e} — will retry on next run",
source_path.display()
);
}
}
} else {
let _ = std::fs::remove_file(&sentinel);
}
}
if !legacy.exists() {
return;
}
if new_dir.exists() {
eprintln!(
"[roboticus] Both ~/.ironclad and ~/.roboticus exist; skipping automatic migration. \
Merge manually and remove ~/.ironclad to silence this warning."
);
return;
}
if std::fs::rename(&legacy, new_dir).is_err() {
if let Err(e) = copy_dir_recursive(&legacy, new_dir) {
eprintln!("[roboticus] failed to copy ~/.ironclad to ~/.roboticus: {e}");
return;
}
if let Err(e) = std::fs::remove_dir_all(&legacy) {
eprintln!(
"[roboticus] copied ~/.ironclad to ~/.roboticus but could not remove the original: {e}"
);
let _ = std::fs::write(&sentinel, legacy.to_string_lossy().as_ref());
}
}
eprintln!("[roboticus] Migrated data directory from ~/.ironclad to ~/.roboticus");
let old_config = new_dir.join("ironclad.toml");
let new_config = new_dir.join("roboticus.toml");
if old_config.exists() && !new_config.exists() {
if let Err(e) = std::fs::rename(&old_config, &new_config) {
eprintln!("[roboticus] failed to rename ironclad.toml to roboticus.toml: {e}");
} else {
eprintln!("[roboticus] Renamed ironclad.toml → roboticus.toml");
}
}
rewrite_all_toml_files(new_dir);
});
}
pub fn rewrite_all_toml_files(dir: &Path) {
let walker = match std::fs::read_dir(dir) {
Ok(w) => w,
Err(_) => return,
};
for entry in walker.flatten() {
let path = entry.path();
if path.is_dir() {
rewrite_all_toml_files(&path);
} else if path.extension().and_then(|e| e.to_str()) == Some("toml") {
rewrite_legacy_paths_in_config(&path);
}
}
}
pub fn rewrite_legacy_paths_in_config(path: &Path) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let rewritten = content
.replace("/.ironclad/", "/.roboticus/")
.replace("\\.ironclad\\", "\\.roboticus\\")
.replace("\\\\.ironclad\\\\", "\\\\.roboticus\\\\")
.replace("/.ironclad\"", "/.roboticus\"")
.replace("/.ironclad'", "/.roboticus'")
.replace("\\.ironclad\"", "\\.roboticus\"")
.replace("\\.ironclad'", "\\.roboticus'")
.replace("\\\\.ironclad\"", "\\\\.roboticus\"")
.replace("\\\\.ironclad'", "\\\\.roboticus'");
if rewritten != content {
if let Err(e) = std::fs::write(path, &rewritten) {
eprintln!(
"[roboticus] failed to rewrite legacy paths in {}: {e}",
path.display()
);
} else {
eprintln!(
"[roboticus] Rewrote legacy paths in {}",
path.display()
);
}
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
pub fn home_dir() -> PathBuf {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir())
}
pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
if let Some(p) = explicit {
return Some(expand_tilde(Path::new(p)));
}
if let Ok(legacy_env) = std::env::var("IRONCLAD_CONFIG") {
eprintln!(
"[roboticus] IRONCLAD_CONFIG is deprecated; use ROBOTICUS_CONFIG instead. \
Falling back to: {legacy_env}"
);
return Some(expand_tilde(Path::new(&legacy_env)));
}
let home = home_dir();
let roboticus_dir = home.join(".roboticus");
migrate_legacy_data_dir(&home, &roboticus_dir);
let home_config = roboticus_dir.join("roboticus.toml");
if home_config.exists() {
return Some(home_config);
}
let cwd_config = PathBuf::from("roboticus.toml");
if cwd_config.exists() {
return Some(cwd_config);
}
let legacy_home = roboticus_dir.join("ironclad.toml");
if legacy_home.exists() {
tracing::info!("Using legacy config file: {}", legacy_home.display());
return Some(legacy_home);
}
let legacy_cwd = PathBuf::from("ironclad.toml");
if legacy_cwd.exists() {
tracing::info!("Using legacy config file: ironclad.toml in current directory");
return Some(legacy_cwd);
}
None
}
fn expand_tilde(path: &Path) -> PathBuf {
if let Ok(stripped) = path.strip_prefix("~") {
home_dir().join(stripped)
} else {
path.to_path_buf()
}
}