mirror of
https://github.com/openai/codex.git
synced 2026-03-03 21:23:18 +00:00
Compare commits
1 Commits
fix/notify
...
codex/nick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d29febae |
@@ -20,6 +20,13 @@
|
||||
"description": {
|
||||
"description": "Human-facing role documentation used in spawn tool guidance.",
|
||||
"type": "string"
|
||||
},
|
||||
"nickname_candidates": {
|
||||
"description": "Candidate nicknames for agents spawned with this role.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::guards::Guards;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
@@ -31,7 +33,7 @@ pub(crate) struct SpawnAgentOptions {
|
||||
pub(crate) fork_parent_spawn_call_id: Option<String>,
|
||||
}
|
||||
|
||||
fn agent_nickname_list() -> Vec<&'static str> {
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -39,6 +41,23 @@ fn agent_nickname_list() -> Vec<&'static str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn agent_nickname_candidates(
|
||||
config: &crate::config::Config,
|
||||
role_name: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
if let Some(candidates) =
|
||||
resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone())
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
default_agent_nickname_list()
|
||||
.into_iter()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
@@ -90,7 +109,10 @@ impl AgentControl {
|
||||
agent_role,
|
||||
..
|
||||
})) => {
|
||||
let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?;
|
||||
let candidate_names = agent_nickname_candidates(&config, agent_role.as_deref());
|
||||
let candidate_name_refs: Vec<&str> =
|
||||
candidate_names.iter().map(String::as_str).collect();
|
||||
let agent_nickname = reservation.reserve_agent_nickname(&candidate_name_refs)?;
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
@@ -219,8 +241,12 @@ impl AgentControl {
|
||||
let reserved_agent_nickname = resumed_agent_nickname
|
||||
.as_deref()
|
||||
.map(|agent_nickname| {
|
||||
let candidate_names =
|
||||
agent_nickname_candidates(&config, resumed_agent_role.as_deref());
|
||||
let candidate_name_refs: Vec<&str> =
|
||||
candidate_names.iter().map(String::as_str).collect();
|
||||
reservation.reserve_agent_nickname_with_preference(
|
||||
&agent_nickname_list(),
|
||||
&candidate_name_refs,
|
||||
Some(agent_nickname),
|
||||
)
|
||||
})
|
||||
@@ -439,6 +465,7 @@ mod tests {
|
||||
use crate::CodexThread;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
@@ -1312,6 +1339,49 @@ mod tests {
|
||||
assert_eq!(agent_role, Some("explorer".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
|
||||
let mut harness = AgentControlHarness::new().await;
|
||||
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;
|
||||
|
||||
@@ -22,15 +22,9 @@ pub(crate) async fn apply_role_to_config(
|
||||
role_name: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
let (config_file, is_built_in) = config
|
||||
.agent_roles
|
||||
.get(role_name)
|
||||
.map(|role| (&role.config_file, false))
|
||||
.or_else(|| {
|
||||
built_in::configs()
|
||||
.get(role_name)
|
||||
.map(|role| (&role.config_file, true))
|
||||
})
|
||||
let is_built_in = !config.agent_roles.contains_key(role_name);
|
||||
let (config_file, is_built_in) = resolve_role_config(config, role_name)
|
||||
.map(|role| (&role.config_file, is_built_in))
|
||||
.ok_or_else(|| format!("unknown agent_type '{role_name}'"))?;
|
||||
let Some(config_file) = config_file.as_ref() else {
|
||||
return Ok(());
|
||||
@@ -100,6 +94,16 @@ pub(crate) async fn apply_role_to_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_role_config<'a>(
|
||||
config: &'a Config,
|
||||
role_name: &str,
|
||||
) -> Option<&'a AgentRoleConfig> {
|
||||
config
|
||||
.agent_roles
|
||||
.get(role_name)
|
||||
.or_else(|| built_in::configs().get(role_name))
|
||||
}
|
||||
|
||||
pub(crate) mod spawn_tool_spec {
|
||||
use super::*;
|
||||
|
||||
@@ -157,6 +161,7 @@ mod built_in {
|
||||
AgentRoleConfig {
|
||||
description: Some("Default agent.".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: None,
|
||||
}
|
||||
),
|
||||
(
|
||||
@@ -171,6 +176,7 @@ Rules:
|
||||
- Run explorers in parallel when useful.
|
||||
- Reuse existing explorers for related questions."#.to_string()),
|
||||
config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()),
|
||||
nickname_candidates: None,
|
||||
}
|
||||
),
|
||||
(
|
||||
@@ -185,6 +191,7 @@ Rules:
|
||||
- Explicitly assign **ownership** of the task (files / responsibility).
|
||||
- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."#.to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: None,
|
||||
}
|
||||
),
|
||||
// Awaiter is temp removed
|
||||
@@ -312,6 +319,7 @@ mod tests {
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(PathBuf::from("/path/does/not/exist.toml")),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -331,6 +339,7 @@ mod tests {
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -361,6 +370,7 @@ mod tests {
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -408,6 +418,7 @@ writable_roots = ["./sandbox-root"]
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -461,6 +472,7 @@ writable_roots = ["./sandbox-root"]
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -501,6 +513,7 @@ enabled = false
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -527,6 +540,7 @@ enabled = false
|
||||
AgentRoleConfig {
|
||||
description: Some("user override".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: None,
|
||||
},
|
||||
),
|
||||
("researcher".to_string(), AgentRoleConfig::default()),
|
||||
@@ -547,6 +561,7 @@ enabled = false
|
||||
AgentRoleConfig {
|
||||
description: Some("first".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use similar::DiffableStr;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
@@ -1363,6 +1364,7 @@ pub struct AgentsToml {
|
||||
/// [agents.researcher]
|
||||
/// description = "Research-focused role."
|
||||
/// config_file = "./agents/researcher.toml"
|
||||
/// nickname_candidates = ["Herodotus", "Ibn Battuta"]
|
||||
/// ```
|
||||
#[serde(default, flatten)]
|
||||
pub roles: BTreeMap<String, AgentRoleToml>,
|
||||
@@ -1374,6 +1376,8 @@ pub struct AgentRoleConfig {
|
||||
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(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
@@ -1385,6 +1389,9 @@ pub struct AgentRoleToml {
|
||||
/// Path to a role-specific config layer.
|
||||
/// Relative paths are resolved relative to the `config.toml` that defines them.
|
||||
pub config_file: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Candidate nicknames for agents spawned with this role.
|
||||
pub nickname_candidates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ToolsToml> for Tools {
|
||||
@@ -1858,11 +1865,16 @@ impl Config {
|
||||
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,
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -2324,6 +2336,46 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent_role_nickname_candidates(
|
||||
role_name: &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!("agents.{role_name}.nickname_candidates 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!("agents.{role_name}.nickname_candidates 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"),
|
||||
));
|
||||
}
|
||||
|
||||
normalized_candidates.push(normalized_nickname.to_owned());
|
||||
}
|
||||
|
||||
Ok(Some(normalized_candidates))
|
||||
}
|
||||
|
||||
pub fn set_windows_sandbox_enabled(&mut self, value: bool) {
|
||||
self.permissions.windows_sandbox_mode = if value {
|
||||
Some(WindowsSandboxModeToml::Unelevated)
|
||||
@@ -4622,6 +4674,7 @@ model = "gpt-5.1-codex"
|
||||
AgentRoleToml {
|
||||
description: Some("Research role".to_string()),
|
||||
config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
@@ -4658,6 +4711,7 @@ model = "gpt-5.1-codex"
|
||||
r#"[agents.researcher]
|
||||
description = "Research role"
|
||||
config_file = "./agents/researcher.toml"
|
||||
nickname_candidates = ["Hypatia", "Noether"]
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
@@ -4674,6 +4728,128 @@ config_file = "./agents/researcher.toml"
|
||||
.and_then(|role| role.config_file.as_ref()),
|
||||
Some(&role_config_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"])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cfg = ConfigToml {
|
||||
agents: Some(AgentsToml {
|
||||
max_threads: None,
|
||||
max_depth: None,
|
||||
job_max_runtime_seconds: None,
|
||||
roles: BTreeMap::from([(
|
||||
"researcher".to_string(),
|
||||
AgentRoleToml {
|
||||
description: Some("Research role".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: Some(vec![
|
||||
" Hypatia ".to_string(),
|
||||
"Noether".to_string(),
|
||||
]),
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
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"])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cfg = ConfigToml {
|
||||
agents: Some(AgentsToml {
|
||||
max_threads: None,
|
||||
max_depth: None,
|
||||
job_max_runtime_seconds: None,
|
||||
roles: BTreeMap::from([(
|
||||
"researcher".to_string(),
|
||||
AgentRoleToml {
|
||||
description: Some("Research role".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: Some(Vec::new()),
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
);
|
||||
let err = result.expect_err("empty nickname candidates should be rejected");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("agents.researcher.nickname_candidates")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cfg = ConfigToml {
|
||||
agents: Some(AgentsToml {
|
||||
max_threads: None,
|
||||
max_depth: None,
|
||||
job_max_runtime_seconds: None,
|
||||
roles: BTreeMap::from([(
|
||||
"researcher".to_string(),
|
||||
AgentRoleToml {
|
||||
description: Some("Research role".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: Some(vec![
|
||||
"Hypatia".to_string(),
|
||||
" Hypatia ".to_string(),
|
||||
]),
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
);
|
||||
let err = result.expect_err("duplicate nickname candidates should be rejected");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("agents.researcher.nickname_candidates cannot contain duplicates")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user