Configure multi_agent_v2 spawn agent hints (#17071)

Allow multi_agent_v2 features to have its own temporary configuration
under `[features.multi_agent_v2]`

```
[features.multi_agent_v2]
enabled = true
usage_hint_enabled = false
usage_hint_text = "Custom delegation guidance."
hide_spawn_agent_metadata = true
```

Absent `usage_hint_text` means use the default hint.

```
[features]
multi_agent_v2 = true
```

still works as the boolean shorthand.
This commit is contained in:
pakrym-oai
2026-04-08 08:42:18 -07:00
committed by GitHub
Unverified
parent 2250fdd54a
commit 4c07dd4d25
18 changed files with 501 additions and 62 deletions
+9
View File
@@ -964,6 +964,9 @@ impl TurnContext {
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_has_environment(self.tools_config.has_environment)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
@@ -1488,6 +1491,9 @@ impl Session {
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_has_environment(environment.is_some())
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&per_turn_config.agent_roles,
));
@@ -5676,6 +5682,9 @@ async fn spawn_review_thread(
.with_web_search_config(/*web_search_config*/ None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_has_environment(parent_turn_context.environment.is_some())
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
+81 -12
View File
@@ -1746,7 +1746,7 @@ fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
let mut entries = BTreeMap::new();
entries.insert("apply_patch_freeform".to_string(), false);
let cfg = ConfigToml {
features: Some(FeaturesToml { entries }),
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
@@ -1794,7 +1794,7 @@ fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()>
let mut entries = BTreeMap::new();
entries.insert(feature_key.to_string(), true);
let cfg = ConfigToml {
features: Some(FeaturesToml { entries }),
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
@@ -3005,14 +3005,14 @@ async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> {
profile
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
Some(&true),
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(true),
);
assert_eq!(
parsed
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
.and_then(|features| features.entries().get("guardian_approval").copied()),
None,
);
@@ -3047,14 +3047,14 @@ async fn set_feature_enabled_persists_default_false_feature_disable_in_profile()
profile
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
Some(&false),
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(false),
);
assert_eq!(
parsed
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
.and_then(|features| features.entries().get("guardian_approval").copied()),
None,
);
@@ -3087,15 +3087,15 @@ async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::
parsed
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
Some(&true),
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(true),
);
assert_eq!(
profile
.features
.as_ref()
.and_then(|features| features.entries.get("guardian_approval")),
Some(&false),
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(false),
);
Ok(())
@@ -4520,6 +4520,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("o3".to_string()),
@@ -4665,6 +4666,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt3".to_string()),
@@ -4808,6 +4810,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("zdr".to_string()),
@@ -4937,6 +4940,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt5".to_string()),
@@ -6096,6 +6100,71 @@ smart_approvals = true
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
usage_hint_enabled = false
usage_hint_text = "Custom delegation guidance."
hide_spawn_agent_metadata = true
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.features.enabled(Feature::MultiAgentV2));
assert!(!config.multi_agent_v2.usage_hint_enabled);
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
Some("Custom delegation guidance.")
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
Ok(())
}
#[tokio::test]
async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"profile = "no_hint"
[features.multi_agent_v2]
usage_hint_enabled = true
usage_hint_text = "base hint"
hide_spawn_agent_metadata = true
[profiles.no_hint.features.multi_agent_v2]
usage_hint_enabled = false
usage_hint_text = "profile hint"
hide_spawn_agent_metadata = false
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(!config.multi_agent_v2.usage_hint_enabled);
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
Some("profile hint")
);
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+6 -6
View File
@@ -202,9 +202,9 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
let mut explicit_settings = Vec::new();
if let Some(features) = cfg.features.as_ref() {
for (key, enabled) in &features.entries {
if let Some(feature) = feature_for_key(key) {
explicit_settings.push((format!("features.{key}"), feature, *enabled));
for (key, enabled) in features.entries() {
if let Some(feature) = feature_for_key(&key) {
explicit_settings.push((format!("features.{key}"), feature, enabled));
}
}
}
@@ -224,12 +224,12 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
}
for (profile_name, profile) in &cfg.profiles {
if let Some(features) = profile.features.as_ref() {
for (key, enabled) in &features.entries {
if let Some(feature) = feature_for_key(key) {
for (key, enabled) in features.entries() {
if let Some(feature) = feature_for_key(&key) {
explicit_settings.push((
format!("profiles.{profile_name}.features.{key}"),
feature,
*enabled,
enabled,
));
}
}
+61
View File
@@ -52,7 +52,10 @@ use codex_config::types::WindowsSandboxModeToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::FeatureToml;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_login::AuthManagerConfig;
use codex_mcp::McpConfig;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
@@ -520,6 +523,9 @@ pub struct Config {
/// Settings for ghost snapshots (used for undo).
pub ghost_snapshot: GhostSnapshotConfig,
/// Settings specific to the task-path-based multi-agent tool surface.
pub multi_agent_v2: MultiAgentV2Config,
/// Centralized feature flags; source of truth for feature gating.
pub features: ManagedFeatures,
@@ -564,6 +570,23 @@ pub struct Config {
pub otel: codex_config::types::OtelConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiAgentV2Config {
pub usage_hint_enabled: bool,
pub usage_hint_text: Option<String>,
pub hide_spawn_agent_metadata: bool,
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
Self {
usage_hint_enabled: true,
usage_hint_text: None,
hide_spawn_agent_metadata: false,
}
}
}
impl AuthManagerConfig for Config {
fn codex_home(&self) -> PathBuf {
self.codex_home.clone()
@@ -1279,6 +1302,42 @@ fn resolve_web_search_config(
}
}
fn resolve_multi_agent_v2_config(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let profile = multi_agent_v2_toml_config(config_profile.features.as_ref());
let default = MultiAgentV2Config::default();
let usage_hint_enabled = profile
.and_then(|config| config.usage_hint_enabled)
.or_else(|| base.and_then(|config| config.usage_hint_enabled))
.unwrap_or(default.usage_hint_enabled);
let usage_hint_text = profile
.and_then(|config| config.usage_hint_text.as_ref())
.or_else(|| base.and_then(|config| config.usage_hint_text.as_ref()))
.cloned()
.or(default.usage_hint_text);
let hide_spawn_agent_metadata = profile
.and_then(|config| config.hide_spawn_agent_metadata)
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
.unwrap_or(default.hide_spawn_agent_metadata);
MultiAgentV2Config {
usage_hint_enabled,
usage_hint_text,
hide_spawn_agent_metadata,
}
}
fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> {
match features?.multi_agent_v2.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
sandbox_policy: &SandboxPolicy,
@@ -1607,6 +1666,7 @@ impl Config {
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile);
let agent_roles =
agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings)?;
@@ -2040,6 +2100,7 @@ impl Config {
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
multi_agent_v2,
features,
suppress_unstable_features_warning: cfg
.suppress_unstable_features_warning
@@ -5,7 +5,6 @@ use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::protocol::InterAgentCommunication;
@@ -207,10 +206,7 @@ impl ToolHandler for Handler {
)
})?;
let hide_agent_metadata = turn
.config
.features
.enabled(Feature::DebugHideSpawnAgentMetadata);
let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata;
if hide_agent_metadata {
Ok(SpawnAgentResult::HiddenMetadata { task_name })
} else {
+72
View File
@@ -32,6 +32,7 @@ use codex_tools::ZshForkConfig;
use codex_tools::mcp_call_tool_result_output_schema;
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::assert_regex_match;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -158,6 +159,39 @@ fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a Co
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
}
fn multi_agent_v2_tools_config() -> ToolsConfig {
let config = test_config();
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
features.enable(Feature::MultiAgentV2);
let available_models = Vec::new();
ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
}
fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String {
let (tools, _) = build_specs(
tools_config,
/*mcp_tools*/ None,
/*app_tools*/ None,
&[],
)
.build();
let spawn_agent = find_tool(&tools, "spawn_agent");
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &spawn_agent.spec else {
panic!("spawn_agent should be a function tool");
};
description.clone()
}
fn model_info_from_models_json(slug: &str) -> ModelInfo {
let config = test_config();
let response = bundled_models_response()
@@ -599,6 +633,44 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
);
}
#[test]
fn spawn_agent_description_omits_usage_hint_when_disabled() {
let tools_config = multi_agent_v2_tools_config()
.with_spawn_agent_usage_hint(/*spawn_agent_usage_hint*/ false);
let description = multi_agent_v2_spawn_agent_description(&tools_config);
assert_regex_match(
r#"(?sx)
^\s*
No\ picker-visible\ models\ are\ currently\ loaded\.
\s+Spawn\ a\ sub-agent\ for\ a\ well-scoped\ task\.
\s+Returns\ the\ canonical\ task\ name\ for\ the\ spawned\ agent,\ plus\ the\ user-facing\ nickname\ when\ available\.
\s*$
"#,
&description,
);
}
#[test]
fn spawn_agent_description_uses_configured_usage_hint_text() {
let tools_config = multi_agent_v2_tools_config().with_spawn_agent_usage_hint_text(Some(
/*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(),
));
let description = multi_agent_v2_spawn_agent_description(&tools_config);
assert_regex_match(
r#"(?sx)
^\s*
No\ picker-visible\ models\ are\ currently\ loaded\.
\s+Spawn\ a\ sub-agent\ for\ a\ well-scoped\ task\.
\s+Returns\ the\ canonical\ task\ name\ for\ the\ spawned\ agent,\ plus\ the\ user-facing\ nickname\ when\ available\.
\s+Custom\ delegation\ guidance\ only\.
\s*$
"#,
&description,
);
}
#[test]
fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info();
+3 -3
View File
@@ -89,7 +89,7 @@ pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml, profile: &Confi
}
fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool {
let Some(entries) = features.map(|features| &features.entries) else {
let Some(entries) = features.map(FeaturesToml::entries) else {
return false;
};
entries.contains_key(Feature::WindowsSandboxElevated.key())
@@ -100,8 +100,8 @@ fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool
pub fn legacy_windows_sandbox_mode(
features: Option<&FeaturesToml>,
) -> Option<WindowsSandboxModeToml> {
let entries = features.map(|features| &features.entries)?;
legacy_windows_sandbox_mode_from_entries(entries)
let entries = features.map(FeaturesToml::entries)?;
legacy_windows_sandbox_mode_from_entries(&entries)
}
pub fn legacy_windows_sandbox_mode_from_entries(
+3 -7
View File
@@ -109,7 +109,7 @@ fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() {
/*value*/ true,
);
let cfg = ConfigToml {
features: Some(FeaturesToml { entries }),
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
@@ -127,9 +127,7 @@ fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_tru
/*value*/ false,
);
let profile = ConfigProfile {
features: Some(FeaturesToml {
entries: profile_entries,
}),
features: Some(FeaturesToml::from(profile_entries)),
..Default::default()
};
@@ -139,9 +137,7 @@ fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_tru
/*value*/ true,
);
let cfg = ConfigToml {
features: Some(FeaturesToml {
entries: cfg_entries,
}),
features: Some(FeaturesToml::from(cfg_entries)),
..Default::default()
};