From fb9311db5ce1910ee71d0f29c074f3af0f72417d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 24 Aug 2026 22:26:14 +0000 Subject: [PATCH] Extract agent role loading into a dedicated crate (#40487) ## What changed - Add `codex-agent-roles` for agent role configuration types, parsing, discovery, validation, and layered loading. - Update `codex-core` to consume the new crate's public role configuration and parsing APIs. GitOrigin-RevId: 5a963a219a581b2848dae6fd071aa31a0b5ff22c --- codex-rs/Cargo.lock | 14 + codex-rs/Cargo.toml | 2 + codex-rs/agent-roles/BUILD.bazel | 6 + codex-rs/agent-roles/Cargo.toml | 23 ++ codex-rs/agent-roles/src/agent_role_config.rs | 209 +++++++++++++++ codex-rs/agent-roles/src/discovery.rs | 40 +++ codex-rs/agent-roles/src/lib.rs | 8 + .../src/loader.rs} | 243 +----------------- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/config/mod.rs | 17 +- 11 files changed, 317 insertions(+), 248 deletions(-) create mode 100644 codex-rs/agent-roles/BUILD.bazel create mode 100644 codex-rs/agent-roles/Cargo.toml create mode 100644 codex-rs/agent-roles/src/agent_role_config.rs create mode 100644 codex-rs/agent-roles/src/discovery.rs create mode 100644 codex-rs/agent-roles/src/lib.rs rename codex-rs/{core/src/config/agent_roles.rs => agent-roles/src/loader.rs} (60%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3b20d8d1d0..c097016f57 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1915,6 +1915,19 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "codex-agent-roles" +version = "0.0.0" +dependencies = [ + "codex-config", + "codex-file-system", + "codex-utils-absolute-path", + "codex-utils-path-uri", + "serde", + "toml 0.9.11+spec-1.1.0", + "tracing", +] + [[package]] name = "codex-analytics" version = "0.0.0" @@ -2747,6 +2760,7 @@ dependencies = [ "chrono", "clap", "codex-agent-graph-store", + "codex-agent-roles", "codex-analytics", "codex-api", "codex-app-server-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4c69bcffee..ea9d044146 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -4,6 +4,7 @@ members = [ "analytics", "agent-graph-store", "agent-identity", + "agent-roles", "backend-client", "bwrap", "build-info", @@ -153,6 +154,7 @@ app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } codex-agent-graph-store = { path = "agent-graph-store" } codex-agent-identity = { path = "agent-identity" } +codex-agent-roles = { path = "agent-roles" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-aws-auth = { path = "aws-auth" } diff --git a/codex-rs/agent-roles/BUILD.bazel b/codex-rs/agent-roles/BUILD.bazel new file mode 100644 index 0000000000..3d03d30346 --- /dev/null +++ b/codex-rs/agent-roles/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-roles", + crate_name = "codex_agent_roles", +) diff --git a/codex-rs/agent-roles/Cargo.toml b/codex-rs/agent-roles/Cargo.toml new file mode 100644 index 0000000000..85fa40ce08 --- /dev/null +++ b/codex-rs/agent-roles/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-roles" +version.workspace = true + +[lib] +doctest = false +name = "codex_agent_roles" +path = "src/lib.rs" +test = false + +[lints] +workspace = true + +[dependencies] +codex-config = { workspace = true } +codex-file-system = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-path-uri = { workspace = true } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true, features = ["preserve_order"] } +tracing = { workspace = true } diff --git a/codex-rs/agent-roles/src/agent_role_config.rs b/codex-rs/agent-roles/src/agent_role_config.rs new file mode 100644 index 0000000000..0e53a71957 --- /dev/null +++ b/codex-rs/agent-roles/src/agent_role_config.rs @@ -0,0 +1,209 @@ +use codex_config::config_toml::ConfigToml; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; +use toml::Value as TomlValue; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentRoleConfig { + /// Human-facing role documentation used in spawn tool guidance. + /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. + pub description: Option, + /// Path to a role-specific config layer. + pub config_file: Option, + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[serde(deny_unknown_fields)] +struct RawAgentRoleFileToml { + name: Option, + description: Option, + nickname_candidates: Option>, + #[serde(flatten)] + config: ConfigToml, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedAgentRoleFile { + pub role_name: String, + pub description: Option, + pub nickname_candidates: Option>, + pub config: TomlValue, +} + +pub fn parse_agent_role_file_contents( + contents: &str, + role_file_label: &Path, + config_base_dir: &Path, + role_name_hint: Option<&str>, +) -> std::io::Result { + 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, + }) +} + +pub(crate) fn normalize_agent_role_description( + field_label: &str, + description: Option<&str>, +) -> std::io::Result> { + 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_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(()), + } +} + +pub(crate) fn normalize_agent_role_nickname_candidates( + field_label: &str, + nickname_candidates: Option<&[String]>, +) -> std::io::Result>> { + 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)) +} diff --git a/codex-rs/agent-roles/src/discovery.rs b/codex-rs/agent-roles/src/discovery.rs new file mode 100644 index 0000000000..79d4aae05a --- /dev/null +++ b/codex-rs/agent-roles/src/discovery.rs @@ -0,0 +1,40 @@ +use codex_file_system::ExecutorFileSystem; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use std::io; +use std::io::ErrorKind; + +pub(crate) async fn collect_agent_role_files( + fs: &dyn ExecutorFileSystem, + dir: &AbsolutePathBuf, +) -> io::Result> { + 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) +} diff --git a/codex-rs/agent-roles/src/lib.rs b/codex-rs/agent-roles/src/lib.rs new file mode 100644 index 0000000000..0ad2a7bd9a --- /dev/null +++ b/codex-rs/agent-roles/src/lib.rs @@ -0,0 +1,8 @@ +mod agent_role_config; +mod discovery; +mod loader; + +pub use agent_role_config::AgentRoleConfig; +pub use agent_role_config::ResolvedAgentRoleFile; +pub use agent_role_config::parse_agent_role_file_contents; +pub use loader::load_agent_roles; diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/agent-roles/src/loader.rs similarity index 60% rename from codex-rs/core/src/config/agent_roles.rs rename to codex-rs/agent-roles/src/loader.rs index 7918da1021..a82649b8e1 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/agent-roles/src/loader.rs @@ -1,23 +1,26 @@ -use super::AgentRoleConfig; +use crate::AgentRoleConfig; +use crate::ResolvedAgentRoleFile; +use crate::agent_role_config::normalize_agent_role_description; +use crate::agent_role_config::normalize_agent_role_nickname_candidates; +use crate::discovery::collect_agent_role_files; +use crate::parse_agent_role_file_contents; use codex_config::ConfigLayerStack; 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_exec_server::GetMetadataOptions; -use codex_exec_server::ReadFileOptions; +use codex_file_system::ExecutorFileSystem; +use codex_file_system::GetMetadataOptions; +use codex_file_system::ReadFileOptions; 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( +pub async fn load_agent_roles( fs: &dyn ExecutorFileSystem, cfg: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -213,106 +216,6 @@ async fn agent_role_config_from_toml( }) } -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] -#[serde(deny_unknown_fields)] -struct RawAgentRoleFileToml { - name: Option, - description: Option, - nickname_candidates: Option>, - #[serde(flatten)] - config: ConfigToml, -} - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct ResolvedAgentRoleFile { - pub(crate) role_name: String, - pub(crate) description: Option, - pub(crate) nickname_candidates: Option>, - 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 { - 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, @@ -331,20 +234,6 @@ async fn read_resolved_agent_role_file( ) } -fn normalize_agent_role_description( - field_label: &str, - description: Option<&str>, -) -> std::io::Result> { - 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>, @@ -359,31 +248,6 @@ fn validate_required_agent_role_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, @@ -423,58 +287,6 @@ async fn validate_agent_role_config_file( } } -fn normalize_agent_role_nickname_candidates( - field_label: &str, - nickname_candidates: Option<&[String]>, -) -> std::io::Result>> { - 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, @@ -521,38 +333,3 @@ async fn discover_agent_roles_in_dir( Ok(roles) } - -async fn collect_agent_role_files( - fs: &dyn ExecutorFileSystem, - dir: &AbsolutePathBuf, -) -> std::io::Result> { - 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) -} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 10817c6a60..384af1d3cd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -25,6 +25,7 @@ chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } codex-agent-graph-store = { workspace = true } +codex-agent-roles = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index f06b735408..baa5aa89c8 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -5,9 +5,9 @@ use crate::config::AgentRoleConfig; use crate::config::Config; -use crate::config::agent_roles::parse_agent_role_file_contents; use crate::config::deserialize_config_toml_with_base; use anyhow::anyhow; +use codex_agent_roles::parse_agent_role_file_contents; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 57a071fd89..dffcfd3f40 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -8,6 +8,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; +use codex_agent_roles::load_agent_roles; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; @@ -155,7 +156,6 @@ use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; -pub(crate) mod agent_roles; mod auth_keyring; pub mod edit; mod managed_features; @@ -170,6 +170,7 @@ mod resolved_permission_profile; mod schema; pub use auth_keyring::bootstrap_auth_config; pub use auth_keyring::resolve_bootstrap_auth_keyring_backend_kind; +pub use codex_agent_roles::AgentRoleConfig; pub use codex_config::ConfigLoadOptions; pub use codex_config::Constrained; pub use codex_config::ConstraintError; @@ -2308,17 +2309,6 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R .map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}"))) } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AgentRoleConfig { - /// Human-facing role documentation used in spawn tool guidance. - /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. - pub description: Option, - /// Path to a role-specific config layer. - pub config_file: Option, - /// Candidate nicknames for agents spawned with this role. - pub nickname_candidates: Option>, -} - fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -3676,8 +3666,7 @@ impl Config { let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = - agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings) - .await?; + load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings).await?; let openai_base_url = cfg .openai_base_url