use super::AgentRoleConfig;use codex_config::ConfigLayerStack;use codex_config::ConfigLayerStackOrdering;use codex_config::config_toml::AgentRoleToml;use codex_config::config_toml::AgentsToml;use codex_config::config_toml::ConfigToml;use codex_exec_server::ExecutorFileSystem;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_absolute_path::AbsolutePathBufGuard;use codex_utils_path_uri::PathUri;use serde::Deserialize;use std::collections::BTreeMap;use std::collections::BTreeSet;use std::io::ErrorKind;use std::path::Path;use std::path::PathBuf;use toml::Value as TomlValue;pub(crate) async fn load_agent_roles( fs: &dyn ExecutorFileSystem, cfg: &ConfigToml, config_layer_stack: &ConfigLayerStack, startup_warnings: &mut Vec<String>,) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> { let layers = config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ); if layers.is_empty() { return load_agent_roles_without_layers(fs, cfg).await; } let mut roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new(); for layer in layers { let mut layer_roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new(); let mut declared_role_files = BTreeSet::new(); let config_folder = layer.config_folder(); let agents_toml = match agents_toml_from_layer(&layer.config, config_folder.as_deref()) { Ok(agents_toml) => agents_toml, Err(err) => { push_agent_role_warning(startup_warnings, err); None } }; if let Some(agents_toml) = agents_toml { for (declared_role_name, role_toml) in &agents_toml.roles { let (role_name, role) = match read_declared_role(fs, declared_role_name, role_toml).await { Ok(role) => role, Err(err) => { push_agent_role_warning(startup_warnings, err); continue; } }; if let Some(config_file) = role.config_file.clone() { declared_role_files.insert(config_file); } if layer_roles.contains_key(&role_name) { push_agent_role_warning( startup_warnings, std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "duplicate agent role name `{role_name}` declared in the same config layer" ), ), ); continue; } layer_roles.insert(role_name, role); } } if let Some(config_folder) = layer.config_folder() { for (role_name, role) in discover_agent_roles_in_dir( fs, &config_folder.join("agents"), &declared_role_files, startup_warnings, ) .await? { if layer_roles.contains_key(&role_name) { push_agent_role_warning( startup_warnings, std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "duplicate agent role name `{role_name}` declared in the same config layer" ), ), ); continue; } layer_roles.insert(role_name, role); } } for (role_name, role) in layer_roles { let mut merged_role = role; if let Some(existing_role) = roles.get(&role_name) { merge_missing_role_fields(&mut merged_role, existing_role); } if let Err(err) = validate_required_agent_role_description( &role_name, merged_role.description.as_deref(), ) { push_agent_role_warning(startup_warnings, err); continue; } roles.insert(role_name, merged_role); } } Ok(roles)}fn push_agent_role_warning(startup_warnings: &mut Vec<String>, err: std::io::Error) { let message = format!("Ignoring malformed agent role definition: {err}"); tracing::warn!("{message}"); startup_warnings.push(message);}async fn load_agent_roles_without_layers( fs: &dyn ExecutorFileSystem, cfg: &ConfigToml,) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> { let mut roles = BTreeMap::new(); if let Some(agents_toml) = cfg.agents.as_ref() { for (declared_role_name, role_toml) in &agents_toml.roles { let (role_name, role) = read_declared_role(fs, declared_role_name, role_toml).await?; validate_required_agent_role_description(&role_name, role.description.as_deref())?; if roles.insert(role_name.clone(), role).is_some() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("duplicate agent role name `{role_name}` declared in config"), )); } } } Ok(roles)}async fn read_declared_role( fs: &dyn ExecutorFileSystem, declared_role_name: &str, role_toml: &AgentRoleToml,) -> std::io::Result<(String, AgentRoleConfig)> { let mut role = agent_role_config_from_toml(fs, declared_role_name, role_toml).await?; let mut role_name = declared_role_name.to_string(); if let Some(config_file) = role.config_file.as_deref() { let config_file = AbsolutePathBuf::from_absolute_path(config_file)?; let parsed_file = read_resolved_agent_role_file(fs, &config_file, Some(declared_role_name)).await?; role_name = parsed_file.role_name; role.description = parsed_file.description.or(role.description); role.nickname_candidates = parsed_file.nickname_candidates.or(role.nickname_candidates); } Ok((role_name, role))}fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleConfig) { role.description = role.description.clone().or(fallback.description.clone()); role.config_file = role.config_file.clone().or(fallback.config_file.clone()); role.nickname_candidates = role .nickname_candidates .clone() .or(fallback.nickname_candidates.clone());}fn agents_toml_from_layer( layer_toml: &TomlValue, config_base_dir: Option<&Path>,) -> std::io::Result<Option<AgentsToml>> { let Some(agents_toml) = layer_toml.get("agents") else { return Ok(None); }; // AbsolutePathBufGuard resolves relative paths while it remains in scope. let _guard = config_base_dir.map(AbsolutePathBufGuard::new); agents_toml .clone() .try_into() .map(Some) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))}async fn agent_role_config_from_toml( fs: &dyn ExecutorFileSystem, role_name: &str, role: &AgentRoleToml,) -> std::io::Result<AgentRoleConfig> { let config_file = role .config_file .as_ref() .map(AbsolutePathBuf::from_absolute_path) .transpose()?; validate_agent_role_config_file(fs, role_name, config_file.as_ref()).await?; let description = normalize_agent_role_description( &format!("agents.{role_name}.description"), role.description.as_deref(), )?; let nickname_candidates = normalize_agent_role_nickname_candidates( &format!("agents.{role_name}.nickname_candidates"), role.nickname_candidates.as_deref(), )?; Ok(AgentRoleConfig { description, config_file: config_file.map(AbsolutePathBuf::into_path_buf), nickname_candidates, })}#[derive(Deserialize, Debug, Clone, Default, PartialEq)]#[serde(deny_unknown_fields)]struct RawAgentRoleFileToml { name: Option<String>, description: Option<String>, nickname_candidates: Option<Vec<String>>, #[serde(flatten)] config: ConfigToml,}#[derive(Debug, Clone, PartialEq)]pub(crate) struct ResolvedAgentRoleFile { pub(crate) role_name: String, pub(crate) description: Option<String>, pub(crate) nickname_candidates: Option<Vec<String>>, pub(crate) config: TomlValue,}pub(crate) fn parse_agent_role_file_contents( contents: &str, role_file_label: &Path, config_base_dir: &Path, role_name_hint: Option<&str>,) -> std::io::Result<ResolvedAgentRoleFile> { let role_file_toml: TomlValue = toml::from_str(contents).map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "failed to parse agent role file at {}: {err}", role_file_label.display() ), ) })?; let _guard = AbsolutePathBufGuard::new(config_base_dir); let parsed: RawAgentRoleFileToml = role_file_toml.clone().try_into().map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "failed to deserialize agent role file at {}: {err}", role_file_label.display() ), ) })?; let description = normalize_agent_role_description( &format!("agent role file {}.description", role_file_label.display()), parsed.description.as_deref(), )?; validate_agent_role_file_developer_instructions( role_file_label, parsed.config.developer_instructions.as_deref(), role_name_hint.is_none(), )?; let role_name = parsed .name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) .map(ToOwned::to_owned) .or_else(|| role_name_hint.map(ToOwned::to_owned)) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agent role file at {} must define a non-empty `name`", role_file_label.display() ), ) })?; let nickname_candidates = normalize_agent_role_nickname_candidates( &format!( "agent role file {}.nickname_candidates", role_file_label.display() ), parsed.nickname_candidates.as_deref(), )?; let mut config = role_file_toml; let Some(config_table) = config.as_table_mut() else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "agent role file at {} must contain a TOML table", role_file_label.display() ), )); }; config_table.remove("name"); config_table.remove("description"); config_table.remove("nickname_candidates"); Ok(ResolvedAgentRoleFile { role_name, description, nickname_candidates, config, })}async fn read_resolved_agent_role_file( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, role_name_hint: Option<&str>,) -> std::io::Result<ResolvedAgentRoleFile> { let path_uri = PathUri::from_abs_path(path); let contents = fs.read_file_text(&path_uri, /*sandbox*/ None).await?; let config_base_dir = path.parent().unwrap_or_else(|| path.clone()); parse_agent_role_file_contents( &contents, path.as_path(), config_base_dir.as_path(), role_name_hint, )}fn normalize_agent_role_description( field_label: &str, description: Option<&str>,) -> std::io::Result<Option<String>> { match description.map(str::trim) { Some("") => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{field_label} cannot be blank"), )), Some(description) => Ok(Some(description.to_string())), None => Ok(None), }}fn validate_required_agent_role_description( role_name: &str, description: Option<&str>,) -> std::io::Result<()> { if description.is_some() { Ok(()) } else { Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("agent role `{role_name}` must define a description"), )) }}fn validate_agent_role_file_developer_instructions( role_file_label: &Path, developer_instructions: Option<&str>, require_present: bool,) -> std::io::Result<()> { match developer_instructions.map(str::trim) { Some("") => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agent role file at {}.developer_instructions cannot be blank", role_file_label.display() ), )), Some(_) => Ok(()), None if require_present => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agent role file at {} must define `developer_instructions`", role_file_label.display() ), )), None => Ok(()), }}async fn validate_agent_role_config_file( fs: &dyn ExecutorFileSystem, role_name: &str, config_file: Option<&AbsolutePathBuf>,) -> std::io::Result<()> { let Some(config_file) = config_file else { return Ok(()); }; let config_file_uri = PathUri::from_abs_path(config_file); let metadata = fs .get_metadata(&config_file_uri, /*sandbox*/ None) .await .map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agents.{role_name}.config_file must point to an existing file at {}: {e}", config_file.as_path().display() ), ) })?; if metadata.is_file { Ok(()) } else { Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agents.{role_name}.config_file must point to a file: {}", config_file.as_path().display() ), )) }}fn normalize_agent_role_nickname_candidates( field_label: &str, nickname_candidates: Option<&[String]>,) -> std::io::Result<Option<Vec<String>>> { let Some(nickname_candidates) = nickname_candidates else { return Ok(None); }; if nickname_candidates.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{field_label} must contain at least one name"), )); } let mut normalized_candidates = Vec::with_capacity(nickname_candidates.len()); let mut seen_candidates = BTreeSet::new(); for nickname in nickname_candidates { let normalized_nickname = nickname.trim(); if normalized_nickname.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{field_label} cannot contain blank names"), )); } if !seen_candidates.insert(normalized_nickname.to_owned()) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{field_label} cannot contain duplicates"), )); } if !normalized_nickname .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_')) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "{field_label} may only contain ASCII letters, digits, spaces, hyphens, and underscores" ), )); } normalized_candidates.push(normalized_nickname.to_owned()); } Ok(Some(normalized_candidates))}async fn discover_agent_roles_in_dir( fs: &dyn ExecutorFileSystem, agents_dir: &AbsolutePathBuf, declared_role_files: &BTreeSet<PathBuf>, startup_warnings: &mut Vec<String>,) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> { let mut roles = BTreeMap::new(); for agent_file in collect_agent_role_files(fs, agents_dir).await? { if declared_role_files.contains(agent_file.as_path()) { continue; } let parsed_file = match read_resolved_agent_role_file(fs, &agent_file, /*role_name_hint*/ None).await { Ok(parsed_file) => parsed_file, Err(err) => { push_agent_role_warning(startup_warnings, err); continue; } }; let role_name = parsed_file.role_name; if roles.contains_key(&role_name) { push_agent_role_warning( startup_warnings, std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "duplicate agent role name `{role_name}` discovered in {}", agents_dir.as_path().display() ), ), ); continue; } roles.insert( role_name, AgentRoleConfig { description: parsed_file.description, config_file: Some(agent_file.to_path_buf()), nickname_candidates: parsed_file.nickname_candidates, }, ); } Ok(roles)}async fn collect_agent_role_files( fs: &dyn ExecutorFileSystem, dir: &AbsolutePathBuf,) -> std::io::Result<Vec<AbsolutePathBuf>> { let mut files = Vec::new(); let mut dirs = vec![dir.clone()]; while let Some(dir) = dirs.pop() { let dir_uri = PathUri::from_abs_path(&dir); let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => return Err(err), }; for entry in entries { let path = dir.join(entry.file_name); if entry.is_directory { dirs.push(path); continue; } if entry.is_file && path .as_path() .extension() .is_some_and(|extension| extension == "toml") { files.push(path); } } } files.sort(); Ok(files)}