From fa9af900e1aca6e912195b15af5838d4c142cb3e Mon Sep 17 00:00:00 2001 From: Nick Baumann <@openai.com> Date: Wed, 18 Mar 2026 22:59:47 -0700 Subject: [PATCH] Add named agent nickname packs --- codex-rs/core/config.schema.json | 34 +++++++++-- codex-rs/core/src/config/config_tests.rs | 49 +++++++++++++++- codex-rs/core/src/config/mod.rs | 73 +++++++++++++++++++++--- 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ec3347f1a6..310a71e55e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -6,6 +6,19 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentNicknameCandidatesToml": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, "AgentRoleToml": { "additionalProperties": false, "properties": { @@ -55,11 +68,24 @@ "type": "integer" }, "nickname_candidates": { - "description": "Global fallback nickname candidates for spawned agent threads.\n\nExample: ```toml [agents] nickname_candidates = [\"Scout\", \"Builder\", \"Reviewer\"] ```", - "items": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AgentNicknameCandidatesToml" + } + ], + "default": null, + "description": "Global fallback nickname candidates for spawned agent threads.\n\nExample: ```toml [agents] nickname_candidates = [\"Scout\", \"Builder\", \"Reviewer\"]\n\n# Or select a named pack: nickname_candidates = \"succession\"\n\n[agents.nickname_packs] succession = [\"Shiv\", \"Roman\", \"Kendall\"] ```" + }, + "nickname_packs": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" }, - "type": "array" + "default": {}, + "description": "Named global fallback nickname candidate packs keyed by pack name.", + "type": "object" } }, "type": "object" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9b04e8467f..5bc349a8e3 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -118,11 +118,11 @@ nickname_candidates = ["Scout", "Builder", "Reviewer"] agents_with_nickname_candidates_cfg .agents .and_then(|agents| agents.nickname_candidates), - Some(vec![ + Some(AgentNicknameCandidatesToml::Candidates(vec![ "Scout".to_string(), "Builder".to_string(), "Reviewer".to_string() - ]) + ])) ); let memories = r#" @@ -1704,6 +1704,46 @@ trust_level = "trusted" Ok(()) } +#[tokio::test] +async fn selected_agents_nickname_pack_is_used() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +[agents] +nickname_candidates = "succession" + +[agents.nickname_packs] +succession = ["Shiv", " Roman ", "", "Kendall", "Shiv"] +the_office = ["Pam", "Jim"] + +[projects."{workspace_key}"] +trust_level = "trusted" +"# + ), + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(workspace.path().to_path_buf())) + .build() + .await?; + + assert_eq!( + config.agent_nickname_candidates, + vec![ + "Shiv".to_string(), + "Roman".to_string(), + "Kendall".to_string() + ] + ); + + Ok(()) +} + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3190,6 +3230,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { max_depth: None, job_max_runtime_seconds: None, nickname_candidates: None, + nickname_packs: BTreeMap::new(), roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4056,6 +4097,7 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<() max_depth: None, job_max_runtime_seconds: None, nickname_candidates: None, + nickname_packs: BTreeMap::new(), roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4098,6 +4140,7 @@ fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result max_depth: None, job_max_runtime_seconds: None, nickname_candidates: None, + nickname_packs: BTreeMap::new(), roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4134,6 +4177,7 @@ fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Re max_depth: None, job_max_runtime_seconds: None, nickname_candidates: None, + nickname_packs: BTreeMap::new(), roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4170,6 +4214,7 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul max_depth: None, job_max_runtime_seconds: None, nickname_candidates: None, + nickname_packs: BTreeMap::new(), roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 006d357414..d217b16ec1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1675,8 +1675,22 @@ pub struct AgentsToml { /// ```toml /// [agents] /// nickname_candidates = ["Scout", "Builder", "Reviewer"] + /// + /// # Or select a named pack: + /// nickname_candidates = "succession" + /// + /// [agents.nickname_packs] + /// succession = ["Shiv", "Roman", "Kendall"] /// ``` - pub nickname_candidates: Option>, + #[serde( + default, + deserialize_with = "deserialize_optional_agent_nickname_candidates" + )] + pub nickname_candidates: Option, + + /// Named global fallback nickname candidate packs keyed by pack name. + #[serde(default)] + pub nickname_packs: BTreeMap>, /// User-defined role declarations keyed by role name. /// @@ -1691,6 +1705,22 @@ pub struct AgentsToml { pub roles: BTreeMap, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum AgentNicknameCandidatesToml { + Candidates(Vec), + Pack(String), +} + +fn deserialize_optional_agent_nickname_candidates<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer) +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentRoleConfig { /// Human-facing role documentation used in spawn tool guidance. @@ -2454,12 +2484,41 @@ impl Config { "agents.job_max_runtime_seconds must fit within a 64-bit signed integer", )); } - let agent_nickname_candidates = agent_roles::normalize_global_agent_nickname_candidates( - "agents.nickname_candidates", - cfg.agents - .as_ref() - .and_then(|agents| agents.nickname_candidates.as_deref()), - )? + let agent_nickname_candidates = { + let candidates = match cfg.agents.as_ref().and_then(|agents| { + agents.nickname_candidates.as_ref().map(|nickname_candidates| { + (nickname_candidates, &agents.nickname_packs) + }) + }) { + Some((AgentNicknameCandidatesToml::Candidates(candidates), _)) => { + Some(candidates.as_slice()) + } + Some((AgentNicknameCandidatesToml::Pack(pack), nickname_packs)) => { + let selected_pack = pack.trim(); + if selected_pack.is_empty() { + None + } else { + match nickname_packs.get(selected_pack) { + Some(candidates) => Some(candidates.as_slice()), + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "agents.nickname_candidates references unknown nickname pack `{selected_pack}`" + ), + )); + } + } + } + } + None => None, + }; + + agent_roles::normalize_global_agent_nickname_candidates( + "agents.nickname_candidates", + candidates, + )? + } .unwrap_or_default(); let background_terminal_max_timeout = cfg .background_terminal_max_timeout