Compare commits

...

3 Commits

Author SHA1 Message Date
Nick Baumann
fa9af900e1 Add named agent nickname packs 2026-03-18 22:59:47 -07:00
Nick Baumann
7014bebfed Align agent candidate config naming 2026-03-18 22:31:11 -07:00
Nick Baumann
8b873c0ba8 Add global subagent nickname pool fallback 2026-03-18 17:34:26 -07:00
7 changed files with 425 additions and 1 deletions

View File

@@ -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": {
@@ -53,6 +66,26 @@
"format": "uint",
"minimum": 1.0,
"type": "integer"
},
"nickname_candidates": {
"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"
},
"default": {},
"description": "Named global fallback nickname candidate packs keyed by pack name.",
"type": "object"
}
},
"type": "object"

View File

@@ -54,6 +54,10 @@ fn agent_nickname_candidates(
return candidates;
}
if !config.agent_nickname_candidates.is_empty() {
return config.agent_nickname_candidates.clone();
}
default_agent_nickname_list()
.into_iter()
.map(ToOwned::to_owned)

View File

@@ -961,6 +961,94 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
assert_eq!(agent_nickname, Some("Atlas".to_string()));
}
#[tokio::test]
async fn spawn_thread_subagent_uses_global_nickname_candidates_when_role_has_no_candidates() {
let mut harness = AgentControlHarness::new().await;
harness.config.agent_nickname_candidates = vec!["Scout".to_string()];
harness.config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: None,
},
);
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("researcher".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let snapshot = child_thread.config_snapshot().await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) =
snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(agent_nickname, Some("Scout".to_string()));
}
#[tokio::test]
async fn spawn_thread_subagent_prefers_role_candidates_over_global_nickname_candidates() {
let mut harness = AgentControlHarness::new().await;
harness.config.agent_nickname_candidates = vec!["Scout".to_string()];
harness.config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec!["Atlas".to_string()]),
},
);
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("researcher".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let snapshot = child_thread.config_snapshot().await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) =
snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(agent_nickname, Some("Atlas".to_string()));
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
let (home, mut config) = test_config().await;

View File

@@ -139,7 +139,7 @@ impl Guards {
active_agents.nickname_reset_count += 1;
if let Some(metrics) = codex_otel::metrics::global() {
let _ = metrics.counter(
"codex.multi_agent.nickname_pool_reset",
"codex.multi_agent.nickname_candidates_reset",
/*inc*/ 1,
&[],
);

View File

@@ -441,6 +441,47 @@ fn normalize_agent_role_nickname_candidates(
Ok(Some(normalized_candidates))
}
pub(crate) fn normalize_global_agent_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);
};
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() {
continue;
}
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"
),
));
}
if seen_candidates.insert(normalized_nickname.to_owned()) {
normalized_candidates.push(normalized_nickname.to_owned());
}
}
if normalized_candidates.is_empty() {
Ok(None)
} else {
Ok(Some(normalized_candidates))
}
}
fn discover_agent_roles_in_dir(
agents_dir: &Path,
declared_role_files: &BTreeSet<PathBuf>,

View File

@@ -107,6 +107,24 @@ persistence = "none"
history_no_persistence_cfg.history
);
let agents_with_nickname_candidates = r#"
[agents]
nickname_candidates = ["Scout", "Builder", "Reviewer"]
"#;
let agents_with_nickname_candidates_cfg =
toml::from_str::<ConfigToml>(agents_with_nickname_candidates)
.expect("TOML deserialization should succeed");
assert_eq!(
agents_with_nickname_candidates_cfg
.agents
.and_then(|agents| agents.nickname_candidates),
Some(AgentNicknameCandidatesToml::Candidates(vec![
"Scout".to_string(),
"Builder".to_string(),
"Reviewer".to_string()
]))
);
let memories = r#"
[memories]
no_memories_if_mcp_or_web_search = true
@@ -1579,6 +1597,153 @@ profile = "project"
Ok(())
}
#[tokio::test]
async fn user_agents_nickname_candidates_are_used_without_project_override() -> 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 = [" Scout ", "", "Builder", "Scout", " ", "Reviewer"]
[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![
"Scout".to_string(),
"Builder".to_string(),
"Reviewer".to_string()
]
);
Ok(())
}
#[tokio::test]
async fn project_agents_nickname_candidates_override_user_candidates() -> 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 = ["Scout", "Builder"]
[projects."{workspace_key}"]
trust_level = "trusted"
"#,
),
)?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join(CONFIG_TOML_FILE),
r#"
[agents]
nickname_candidates = ["Reviewer"]
"#,
)?;
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!["Reviewer".to_string()]
);
Ok(())
}
#[tokio::test]
async fn empty_agents_nickname_candidates_preserve_default_behavior() -> 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 = ["", " "]
[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!(config.agent_nickname_candidates.is_empty());
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()?;
@@ -3064,6 +3229,8 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> {
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
nickname_candidates: None,
nickname_packs: BTreeMap::new(),
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -3929,6 +4096,8 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
nickname_candidates: None,
nickname_packs: BTreeMap::new(),
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -3970,6 +4139,8 @@ fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
nickname_candidates: None,
nickname_packs: BTreeMap::new(),
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4005,6 +4176,8 @@ fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Re
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
nickname_candidates: None,
nickname_packs: BTreeMap::new(),
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4040,6 +4213,8 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
nickname_candidates: None,
nickname_packs: BTreeMap::new(),
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4284,6 +4459,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname_candidates: Vec::new(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -4425,6 +4601,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname_candidates: Vec::new(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -4564,6 +4741,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname_candidates: Vec::new(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -4689,6 +4867,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname_candidates: Vec::new(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,

View File

@@ -417,6 +417,9 @@ pub struct Config {
/// Maximum nesting depth allowed for spawned agent threads.
pub agent_max_depth: i32,
/// Global fallback nickname candidates for spawned agent threads.
pub agent_nickname_candidates: Vec<String>,
/// User-defined role declarations keyed by role name.
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
@@ -1666,6 +1669,29 @@ pub struct AgentsToml {
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// Global fallback nickname candidates for spawned agent threads.
///
/// Example:
/// ```toml
/// [agents]
/// nickname_candidates = ["Scout", "Builder", "Reviewer"]
///
/// # Or select a named pack:
/// nickname_candidates = "succession"
///
/// [agents.nickname_packs]
/// succession = ["Shiv", "Roman", "Kendall"]
/// ```
#[serde(
default,
deserialize_with = "deserialize_optional_agent_nickname_candidates"
)]
pub nickname_candidates: Option<AgentNicknameCandidatesToml>,
/// Named global fallback nickname candidate packs keyed by pack name.
#[serde(default)]
pub nickname_packs: BTreeMap<String, Vec<String>>,
/// User-defined role declarations keyed by role name.
///
/// Example:
@@ -1679,6 +1705,22 @@ pub struct AgentsToml {
pub roles: BTreeMap<String, AgentRoleToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum AgentNicknameCandidatesToml {
Candidates(Vec<String>),
Pack(String),
}
fn deserialize_optional_agent_nickname_candidates<'de, D>(
deserializer: D,
) -> Result<Option<AgentNicknameCandidatesToml>, D::Error>
where
D: Deserializer<'de>,
{
Option::<AgentNicknameCandidatesToml>::deserialize(deserializer)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentRoleConfig {
/// Human-facing role documentation used in spawn tool guidance.
@@ -2442,6 +2484,42 @@ impl Config {
"agents.job_max_runtime_seconds must fit within a 64-bit signed integer",
));
}
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
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
@@ -2707,6 +2785,7 @@ impl Config {
tool_output_token_limit: cfg.tool_output_token_limit,
agent_max_threads,
agent_max_depth,
agent_nickname_candidates,
agent_roles,
memories: cfg.memories.unwrap_or_default().into(),
agent_job_max_runtime_seconds,