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
This commit is contained in:
rhan-oai
2026-08-24 22:40:35 +00:00
committed by copyberry
parent 49880081fe
commit fb9311db5c
11 changed files with 317 additions and 248 deletions
+14
View File
@@ -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",
+2
View File
@@ -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" }
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "agent-roles",
crate_name = "codex_agent_roles",
)
+23
View File
@@ -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 }
@@ -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<String>,
/// Path to a role-specific config layer.
pub config_file: Option<PathBuf>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
#[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 struct ResolvedAgentRoleFile {
pub role_name: String,
pub description: Option<String>,
pub nickname_candidates: Option<Vec<String>>,
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<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,
})
}
pub(crate) 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_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<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))
}
+40
View File
@@ -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<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)
}
+8
View File
@@ -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;
@@ -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<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,
@@ -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<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>,
@@ -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<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,
@@ -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<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)
}
+1
View File
@@ -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 }
+1 -1
View File
@@ -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;
+3 -14
View File
@@ -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<String>,
/// Path to a role-specific config layer.
pub config_file: Option<PathBuf>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
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