Compare commits

...

3 Commits

Author SHA1 Message Date
Gabriel Cohen
816c8e24a3 codex: fix layered agent role metadata merge
Co-authored-by: Codex <noreply@openai.com>
2026-03-10 01:46:21 -07:00
Gabriel Cohen
de7ab6e1d8 codex: sync schema and tests after rebase
Co-authored-by: Codex <noreply@openai.com>
2026-03-10 01:38:38 -07:00
Gabriel Cohen
98dbdfe71f codex: load agent metadata from role files
Load role metadata from standalone agent files, keep legacy config-file behavior, and add recursive agents directory discovery plus precedence tests.

Co-authored-by: Codex <noreply@openai.com>
2026-03-10 01:10:47 -07:00
4 changed files with 1108 additions and 57 deletions

View File

@@ -18,7 +18,7 @@
"description": "Path to a role-specific config layer. Relative paths are resolved relative to the `config.toml` that defines them."
},
"description": {
"description": "Human-facing role documentation used in spawn tool guidance.",
"description": "Human-facing role documentation used in spawn tool guidance. Required unless supplied by the referenced agent role file.",
"type": "string"
},
"nickname_candidates": {

View File

@@ -10,6 +10,7 @@ use crate::config::AgentRoleConfig;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::deserialize_config_toml_with_base;
use crate::config::parse_agent_role_file_contents;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
@@ -46,26 +47,34 @@ pub(crate) async fn apply_role_to_config(
return Ok(());
};
let (role_config_contents, role_config_base) = if is_built_in {
(
built_in::config_file_contents(config_file)
.map(str::to_owned)
.ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?,
config.codex_home.as_path(),
)
let (role_config_toml, role_config_base) = if is_built_in {
let role_config_contents = built_in::config_file_contents(config_file)
.map(str::to_owned)
.ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?;
let role_config_toml: TomlValue = toml::from_str(&role_config_contents)
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?;
(role_config_toml, config.codex_home.as_path())
} else {
let role_config_contents = tokio::fs::read_to_string(config_file)
.await
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?;
let role_config_toml = parse_agent_role_file_contents(
&role_config_contents,
config_file,
config_file
.parent()
.ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?,
Some(role_name),
)
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?
.config;
(
tokio::fs::read_to_string(config_file)
.await
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?,
role_config_toml,
config_file
.parent()
.ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?,
)
};
let role_config_toml: TomlValue = toml::from_str(&role_config_contents)
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?;
deserialize_config_toml_with_base(role_config_toml.clone(), role_config_base)
.map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?;
let role_layer_toml = resolve_relative_paths_in_config_toml(role_config_toml, role_config_base)
@@ -391,6 +400,37 @@ mod tests {
assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR);
}
#[tokio::test]
async fn apply_role_ignores_agent_metadata_fields_in_user_role_file() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(
&home,
"metadata-role.toml",
r#"
name = "archivist"
description = "Role metadata"
nickname_candidates = ["Hypatia"]
developer_instructions = "Stay focused"
model = "role-model"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("role-model"));
}
#[tokio::test]
async fn apply_role_preserves_unspecified_keys() {
let (home, mut config) = test_config_with_cli_overrides(vec![(
@@ -403,7 +443,7 @@ mod tests {
let role_path = write_role_config(
&home,
"effort-only.toml",
"model_reasoning_effort = \"high\"",
"developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"",
)
.await;
config.agent_roles.insert(
@@ -459,7 +499,12 @@ model_provider = "test-provider"
.build()
.await
.expect("load config");
let role_path = write_role_config(&home, "empty-role.toml", "").await;
let role_path = write_role_config(
&home,
"empty-role.toml",
"developer_instructions = \"Stay focused\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
@@ -515,8 +560,12 @@ model_provider = "role-provider"
.build()
.await
.expect("load config");
let role_path =
write_role_config(&home, "profile-role.toml", "profile = \"role-profile\"").await;
let role_path = write_role_config(
&home,
"profile-role.toml",
"developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
@@ -572,7 +621,7 @@ model_provider = "base-provider"
let role_path = write_role_config(
&home,
"provider-role.toml",
"model_provider = \"role-provider\"",
"developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"",
)
.await;
config.agent_roles.insert(
@@ -631,7 +680,9 @@ model_reasoning_effort = "low"
let role_path = write_role_config(
&home,
"profile-edit-role.toml",
r#"[profiles.base-profile]
r#"developer_instructions = "Stay focused"
[profiles.base-profile]
model_provider = "role-provider"
model_reasoning_effort = "high"
"#,
@@ -674,7 +725,9 @@ model_reasoning_effort = "high"
let role_path = write_role_config(
&home,
"sandbox-role.toml",
r#"[sandbox_workspace_write]
r#"developer_instructions = "Stay focused"
[sandbox_workspace_write]
writable_roots = ["./sandbox-root"]
"#,
)
@@ -732,7 +785,12 @@ writable_roots = ["./sandbox-root"]
)])
.await;
let before_layers = session_flags_layer_count(&config);
let role_path = write_role_config(&home, "model-role.toml", "model = \"role-model\"").await;
let role_path = write_role_config(
&home,
"model-role.toml",
"developer_instructions = \"Stay focused\"\nmodel = \"role-model\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
@@ -766,7 +824,9 @@ writable_roots = ["./sandbox-root"]
&home,
"skills-role.toml",
&format!(
r#"[[skills.config]]
r#"developer_instructions = "Stay focused"
[[skills.config]]
path = "{}"
enabled = false
"#,

View File

@@ -2789,7 +2789,11 @@ async fn agent_role_relative_config_file_resolves_against_config_toml() -> std::
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(&role_config_path, "model = \"gpt-5\"").await?;
tokio::fs::write(
&role_config_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
@@ -2824,6 +2828,634 @@ nickname_candidates = ["Hypatia", "Noether"]
Ok(())
}
#[tokio::test]
async fn agent_role_file_metadata_overrides_config_toml_metadata() -> 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,
r#"
description = "Role metadata from file"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research carefully"
model = "gpt-5"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Noether"]
"#,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
let role = config
.agent_roles
.get("researcher")
.expect("researcher role should load");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
assert_eq!(
role.nickname_candidates
.as_ref()
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_requires_developer_instructions() -> 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,
r#"
description = "Role metadata from file"
model = "gpt-5"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("agent role file without developer instructions should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("must define `developer_instructions`")
);
Ok(())
}
#[tokio::test]
async fn agent_role_requires_description_after_merge() -> 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,
r#"
developer_instructions = "Research carefully"
model = "gpt-5"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("agent role without description should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("agent role `researcher` must define a description")
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_name_takes_precedence_over_config_key() -> 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,
r#"
name = "archivist"
description = "Role metadata from file"
developer_instructions = "Research carefully"
model = "gpt-5"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.agent_roles.contains_key("researcher"), false);
let role = config
.agent_roles
.get("archivist")
.expect("role should use file-provided name");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
Ok(())
}
#[tokio::test]
async fn loads_legacy_split_agent_roles_from_config_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let researcher_path = codex_home.path().join("agents").join("researcher.toml");
let reviewer_path = codex_home.path().join("agents").join("reviewer.toml");
tokio::fs::create_dir_all(
researcher_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&researcher_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
tokio::fs::write(
&reviewer_path,
"developer_instructions = \"Review carefully\"\nmodel = \"gpt-4.1\"",
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Hypatia", "Noether"]
[agents.reviewer]
description = "Review role"
config_file = "./agents/reviewer.toml"
nickname_candidates = ["Atlas"]
"#,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&researcher_path)
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia", "Noether"])
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.config_file.as_ref()),
Some(&reviewer_path)
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Atlas"])
);
Ok(())
}
#[tokio::test]
async fn discovers_multiple_standalone_agent_role_files() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)?;
let root_agent = repo_root
.path()
.join(".codex")
.join("agents")
.join("root.toml");
std::fs::create_dir_all(
root_agent
.parent()
.expect("root agent should have a parent directory"),
)?;
std::fs::write(
&root_agent,
r#"
name = "researcher"
description = "from root"
developer_instructions = "Research carefully"
"#,
)?;
let nested_agent = repo_root
.path()
.join("packages")
.join(".codex")
.join("agents")
.join("review")
.join("nested.toml");
std::fs::create_dir_all(
nested_agent
.parent()
.expect("nested agent should have a parent directory"),
)?;
std::fs::write(
&nested_agent,
r#"
name = "reviewer"
description = "from nested"
nickname_candidates = ["Atlas"]
developer_instructions = "Review carefully"
"#,
)?;
let sibling_agent = repo_root
.path()
.join("packages")
.join(".codex")
.join("agents")
.join("writer.toml");
std::fs::create_dir_all(
sibling_agent
.parent()
.expect("sibling agent should have a parent directory"),
)?;
std::fs::write(
&sibling_agent,
r#"
name = "writer"
description = "from sibling"
nickname_candidates = ["Sagan"]
developer_instructions = "Write carefully"
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("from root")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("from nested")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Atlas"])
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.description.as_deref()),
Some("from sibling")
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Sagan"])
);
Ok(())
}
#[tokio::test]
async fn mixed_legacy_and_standalone_agent_role_sources_merge_with_precedence()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Noether"]
[agents.critic]
description = "Critic role from config"
config_file = "./agents/critic.toml"
nickname_candidates = ["Ada"]
"#
),
)
.await?;
let home_agents_dir = codex_home.path().join("agents");
tokio::fs::create_dir_all(&home_agents_dir).await?;
tokio::fs::write(
home_agents_dir.join("researcher.toml"),
r#"
developer_instructions = "Research carefully"
model = "gpt-5"
"#,
)
.await?;
tokio::fs::write(
home_agents_dir.join("critic.toml"),
r#"
developer_instructions = "Critique carefully"
model = "gpt-4.1"
"#,
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
description = "Research role from file"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research from file"
model = "gpt-5-mini"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("writer.toml"),
r#"
name = "writer"
description = "Writer role from file"
nickname_candidates = ["Sagan"]
developer_instructions = "Write carefully"
model = "gpt-5"
"#,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from file")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&standalone_agents_dir.join("researcher.toml"))
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.description.as_deref()),
Some("Critic role from config")
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.config_file.as_ref()),
Some(&home_agents_dir.join("critic.toml"))
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Ada"])
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.description.as_deref()),
Some("Writer role from file")
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Sagan"])
);
Ok(())
}
#[tokio::test]
async fn higher_precedence_agent_role_can_inherit_description_from_lower_layer()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#
),
)
.await?;
let home_agents_dir = codex_home.path().join("agents");
tokio::fs::create_dir_all(&home_agents_dir).await?;
tokio::fs::write(
home_agents_dir.join("researcher.toml"),
r#"
developer_instructions = "Research carefully"
model = "gpt-5"
"#,
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research from file"
model = "gpt-5-mini"
"#,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from config")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&standalone_agents_dir.join("researcher.toml"))
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
Ok(())
}
#[test]
fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -1423,6 +1423,7 @@ pub struct AgentsToml {
#[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>,
@@ -1434,6 +1435,7 @@ pub struct AgentRoleConfig {
#[schemars(deny_unknown_fields)]
pub struct AgentRoleToml {
/// Human-facing role documentation used in spawn tool guidance.
/// Required unless supplied by the referenced agent role file.
pub description: Option<String>,
/// Path to a role-specific config layer.
@@ -1444,6 +1446,105 @@ pub struct AgentRoleToml {
pub nickname_candidates: Option<Vec<String>>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub(crate) struct AgentRoleFileToml {
pub name: Option<String>,
pub description: Option<String>,
pub nickname_candidates: Option<Vec<String>>,
#[serde(flatten)]
pub config: ConfigToml,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ParsedAgentRoleFile {
pub role_name: String,
pub description: Option<String>,
pub nickname_candidates: Option<Vec<String>>,
pub 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<ParsedAgentRoleFile> {
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: AgentRoleFileToml = 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 = Config::normalize_agent_role_description(
&format!("agent role file {}.description", role_file_label.display()),
parsed.description.as_deref(),
)?;
Config::validate_required_agent_role_file_developer_instructions(
role_file_label,
parsed.config.developer_instructions.as_deref(),
)?;
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 = Config::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(ParsedAgentRoleFile {
role_name,
description,
nickname_candidates,
config,
})
}
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
@@ -2046,6 +2147,8 @@ impl Config {
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let agent_roles = Self::load_agent_roles(&cfg, &config_layer_stack)?;
let mut model_providers = built_in_model_providers();
// Merge user-defined providers into the built-in list.
for (key, provider) in cfg.model_providers.into_iter() {
@@ -2095,34 +2198,6 @@ impl Config {
"agents.max_depth must be at least 1",
));
}
let agent_roles = cfg
.agents
.as_ref()
.map(|agents| {
agents
.roles
.iter()
.map(|(name, role)| {
let config_file =
role.config_file.as_ref().map(AbsolutePathBuf::to_path_buf);
Self::validate_agent_role_config_file(name, config_file.as_deref())?;
let nickname_candidates = Self::normalize_agent_role_nickname_candidates(
name,
role.nickname_candidates.as_deref(),
)?;
Ok((
name.clone(),
AgentRoleConfig {
description: role.description.clone(),
config_file,
nickname_candidates,
},
))
})
.collect::<std::io::Result<BTreeMap<_, _>>>()
})
.transpose()?
.unwrap_or_default();
let agent_job_max_runtime_seconds = cfg
.agents
.as_ref()
@@ -2567,6 +2642,239 @@ impl Config {
}
}
fn load_agent_roles(
cfg: &ConfigToml,
config_layer_stack: &ConfigLayerStack,
) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> {
let layers =
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false);
if layers.is_empty() {
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 mut role =
Self::agent_role_config_from_toml(declared_role_name, role_toml)?;
let mut role_name = declared_role_name.clone();
if let Some(config_file) = role.config_file.as_deref() {
let parsed = Self::read_parsed_agent_role_file(
config_file,
Some(declared_role_name.as_str()),
)?;
role_name = parsed.role_name;
role.description = parsed.description.or(role.description);
role.nickname_candidates =
parsed.nickname_candidates.or(role.nickname_candidates);
}
Self::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"),
));
}
}
}
return Ok(roles);
}
let mut roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
for layer in layers {
let mut layer_roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
if let Some(agents_toml) = Self::agents_toml_from_layer(&layer.config)? {
for (declared_role_name, role_toml) in &agents_toml.roles {
let mut role =
Self::agent_role_config_from_toml(declared_role_name, role_toml)?;
let mut role_name = declared_role_name.clone();
if let Some(config_file) = role.config_file.as_deref() {
let parsed = Self::read_parsed_agent_role_file(
config_file,
Some(declared_role_name.as_str()),
)?;
role_name = parsed.role_name;
role.description = parsed.description.or(role.description);
role.nickname_candidates =
parsed.nickname_candidates.or(role.nickname_candidates);
}
if let Some(existing_role) = layer_roles.get(&role_name) {
role.description = role.description.or(existing_role.description.clone());
role.config_file = role.config_file.or(existing_role.config_file.clone());
role.nickname_candidates = role
.nickname_candidates
.or(existing_role.nickname_candidates.clone());
}
if layer_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 the same config layer"
),
));
}
}
}
if let Some(config_folder) = layer.config_folder() {
for (role_name, role) in Self::discover_agent_roles_in_dir(
config_folder.as_path().join("agents").as_path(),
)? {
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) {
merged_role.description = merged_role
.description
.or(existing_role.description.clone());
merged_role.config_file = merged_role
.config_file
.or(existing_role.config_file.clone());
merged_role.nickname_candidates = merged_role
.nickname_candidates
.or(existing_role.nickname_candidates.clone());
}
Self::validate_required_agent_role_description(
&role_name,
merged_role.description.as_deref(),
)?;
roles.insert(role_name, merged_role);
}
}
Ok(roles)
}
fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result<Option<AgentsToml>> {
let Some(agents_toml) = layer_toml.get("agents") else {
return Ok(None);
};
agents_toml
.clone()
.try_into()
.map(Some)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
}
fn agent_role_config_from_toml(
role_name: &str,
role: &AgentRoleToml,
) -> std::io::Result<AgentRoleConfig> {
let config_file = role.config_file.as_ref().map(AbsolutePathBuf::to_path_buf);
Self::validate_agent_role_config_file(role_name, config_file.as_deref())?;
let description = Self::normalize_agent_role_description(
&format!("agents.{role_name}.description"),
role.description.as_deref(),
)?;
let nickname_candidates = Self::normalize_agent_role_nickname_candidates(
&format!("agents.{role_name}.nickname_candidates"),
role.nickname_candidates.as_deref(),
)?;
Ok(AgentRoleConfig {
description,
config_file,
nickname_candidates,
})
}
fn discover_agent_roles_in_dir(
agents_dir: &Path,
) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> {
let mut roles = BTreeMap::new();
for agent_file in Self::collect_agent_role_files(agents_dir)? {
let parsed = match Self::read_parsed_agent_role_file(&agent_file, None) {
Ok(parsed) => parsed,
Err(err)
if err.kind() == std::io::ErrorKind::InvalidInput
&& err.to_string().contains("must define a non-empty `name`") =>
{
continue;
}
Err(err) => return Err(err),
};
let role_name = parsed.role_name;
if roles
.insert(
role_name.clone(),
AgentRoleConfig {
description: parsed.description,
config_file: Some(agent_file),
nickname_candidates: parsed.nickname_candidates,
},
)
.is_some()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"duplicate agent role name `{role_name}` discovered in {}",
agents_dir.display()
),
));
}
}
Ok(roles)
}
fn collect_agent_role_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
Self::collect_agent_role_files_recursive(dir, &mut files)?;
files.sort();
Ok(files)
}
fn collect_agent_role_files_recursive(
dir: &Path,
files: &mut Vec<PathBuf>,
) -> std::io::Result<()> {
let read_dir = match std::fs::read_dir(dir) {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
for entry in read_dir {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
Self::collect_agent_role_files_recursive(&path, files)?;
continue;
}
if file_type.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "toml")
{
files.push(path);
}
}
Ok(())
}
fn read_parsed_agent_role_file(
path: &Path,
role_name_hint: Option<&str>,
) -> std::io::Result<ParsedAgentRoleFile> {
let contents = std::fs::read_to_string(path)?;
parse_agent_role_file_contents(
&contents,
path,
path.parent().unwrap_or(path),
role_name_hint,
)
}
fn validate_agent_role_config_file(
role_name: &str,
config_file: Option<&Path>,
@@ -2597,8 +2905,59 @@ impl Config {
}
}
fn normalize_agent_role_nickname_candidates(
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_required_agent_role_file_developer_instructions(
role_file_label: &Path,
developer_instructions: Option<&str>,
) -> 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 => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"agent role file at {} must define `developer_instructions`",
role_file_label.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 {
@@ -2608,7 +2967,7 @@ impl Config {
if nickname_candidates.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("agents.{role_name}.nickname_candidates must contain at least one name"),
format!("{field_label} must contain at least one name"),
));
}
@@ -2620,14 +2979,14 @@ impl Config {
if normalized_nickname.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("agents.{role_name}.nickname_candidates cannot contain blank names"),
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!("agents.{role_name}.nickname_candidates cannot contain duplicates"),
format!("{field_label} cannot contain duplicates"),
));
}
@@ -2638,7 +2997,7 @@ impl Config {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"agents.{role_name}.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores"
"{field_label} may only contain ASCII letters, digits, spaces, hyphens, and underscores"
),
));
}