Resolve relative agent role config paths from layers (#19261)

Fixes #19257.

## Summary

Agent roles declared in config layers can set `config_file` to a
relative path, but deserializing the layer-local `[agents.*]` table
happened without an `AbsolutePathBuf` base path. That caused configs
like `config_file = "agents/my-role.toml"` to fail with `AbsolutePathBuf
deserialized without a base path`.

This updates agent role layer loading to deserialize `[agents.*]` while
the layer config folder is active as the path base, matching the
behavior documented for `AgentRoleToml.config_file`. It also adds
coverage for a user config layer with a relative agent role
`config_file`.
This commit is contained in:
Eric Traut
2026-04-23 23:23:11 -07:00
committed by GitHub
Unverified
parent 4816b89204
commit d87d918716
2 changed files with 65 additions and 2 deletions
+8 -2
View File
@@ -33,7 +33,8 @@ pub(crate) async fn load_agent_roles(
for layer in layers {
let mut layer_roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
let mut declared_role_files = BTreeSet::new();
let agents_toml = match agents_toml_from_layer(&layer.config) {
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);
@@ -169,11 +170,16 @@ fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleCon
.or(fallback.nickname_candidates.clone());
}
fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result<Option<AgentsToml>> {
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()
+57
View File
@@ -4017,6 +4017,63 @@ nickname_candidates = ["Hypatia", "Noether"]
Ok(())
}
#[tokio::test]
async fn agent_role_relative_config_file_resolves_from_config_layer() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
let layer_config = toml::from_str(
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
"#,
)
.expect("agent role layer config should parse");
let config_layer_stack = crate::config_loader::ConfigLayerStack::new(
vec![crate::config_loader::ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: codex_home.path().join(CONFIG_TOML_FILE).abs(),
},
layer_config,
)],
Default::default(),
crate::config_loader::ConfigRequirementsToml::default(),
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> {
let codex_home = TempDir::new()?;