multi-agent: move concurrency guidance into v2 usage hints (#27569)

## Why

Native Codex currently teaches multi-agent concurrency through the
`spawn_agent` tool description, while bridge-driven evals frame the same
limit as a shared pool of active agent slots. That mismatch makes the
model-facing story harder to reason about, especially because the
tool-level wording does not make it explicit that the limit covers the
whole agent team, including the current agent.

This change gives native Codex the same mental model: tell the root
agent and subagents how many active slots exist, and remove the separate
`spawn_agent` limit wording.

## What changed

- Extend the built-in `multi_agent_v2` root and subagent usage hints
with shared-slot guidance derived from the resolved
`max_concurrent_threads_per_session` value.
- Keep the complete default hints in `MultiAgentV2Config` so initial
context and forked histories consume the same canonical strings.
- Drop the redundant `spawn_agent` description text and remove the
now-unused limit plumbing from the tool spec path.

## Testing

- `just test -p codex-core usage_hint`
- `just test -p codex-core
multi_agent_v2_default_session_thread_cap_counts_root`
- `just test -p codex-core
multi_agent_v2_default_usage_hints_use_configured_thread_cap`
- `just test -p codex-core
spawn_agent_tool_v2_requires_task_name_and_lists_visible_models`
- `just test -p codex-core
multi_agent_feature_selects_one_agent_tool_family`
This commit is contained in:
jif
2026-06-11 12:41:44 +02:00
committed by GitHub
parent 7b3eb8e4bc
commit 0870352241
6 changed files with 55 additions and 76 deletions
+23 -30
View File
@@ -9831,10 +9831,7 @@ enabled = true
.build()
.await?;
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
assert_eq!(config.multi_agent_v2, MultiAgentV2Config::default());
assert_eq!(
(
config.agent_max_threads,
@@ -9842,36 +9839,32 @@ enabled = true
),
(None, Some(3))
);
assert_eq!(
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT)
);
assert!(
!config
.multi_agent_v2
.root_agent_usage_hint_text
.as_deref()
.unwrap_or_default()
.contains("maximum concurrency"),
);
assert_eq!(
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT)
);
assert!(
!config
.multi_agent_v2
.subagent_usage_hint_text
.as_deref()
.unwrap_or_default()
.contains("maximum concurrency"),
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
Ok(())
}
#[test]
fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 17
"#,
)
.expect("multi-agent v2 config should parse");
let config = resolve_multi_agent_v2_config(&config_toml);
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
assert!(
[
config.root_agent_usage_hint_text,
config.subagent_usage_hint_text,
]
.into_iter()
.all(|hint| hint.is_some_and(|hint| hint.ends_with(concurrency_guidance)))
);
}
#[tokio::test]
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+29 -13
View File
@@ -233,6 +233,13 @@ Payload:
```
You may also see them addressed as to=/root/..., which indicates your identity is /root/...
"#;
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
format!(
"{usage_hint_text}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
)
}
pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0;
pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 =
DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
@@ -1055,22 +1062,23 @@ pub struct MultiAgentV2Config {
pub non_code_mode_only: bool,
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
impl MultiAgentV2Config {
fn defaults_for_max_concurrency(max_concurrent_threads_per_session: usize) -> Self {
Self {
max_concurrent_threads_per_session:
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
max_concurrent_threads_per_session,
min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS,
max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS,
default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS,
usage_hint_enabled: true,
usage_hint_text: None,
root_agent_usage_hint_text: Some(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(),
),
subagent_usage_hint_text: Some(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
),
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
)),
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
)),
tool_namespace: None,
hide_spawn_agent_metadata: true,
non_code_mode_only: true,
@@ -1078,6 +1086,14 @@ impl Default for MultiAgentV2Config {
}
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
Self::defaults_for_max_concurrency(
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TerminalResizeReflowMaxRows {
/// Use the runtime terminal detector to choose a scrollback-sized cap.
@@ -2313,11 +2329,11 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let default = MultiAgentV2Config::default();
let max_concurrent_threads_per_session = base
.and_then(|config| config.max_concurrent_threads_per_session)
.unwrap_or(default.max_concurrent_threads_per_session);
.unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION);
let default =
MultiAgentV2Config::defaults_for_max_concurrency(max_concurrent_threads_per_session);
let min_wait_timeout_ms = base
.and_then(|config| config.min_wait_timeout_ms)
.unwrap_or(default.min_wait_timeout_ms);
@@ -26,7 +26,6 @@ pub struct SpawnAgentToolOptions {
pub hide_agent_type_model_reasoning: bool,
pub include_usage_hint: bool,
pub usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -102,7 +101,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
inherited_model_guidance,
options.include_usage_hint,
options.usage_hint_text,
options.max_concurrent_threads_per_session,
),
strict: false,
defer_loading: None,
@@ -722,17 +720,9 @@ fn spawn_agent_tool_description_v2(
inherited_model_guidance: Option<&str>,
include_usage_hint: bool,
usage_hint_text: Option<String>,
max_concurrent_threads_per_session: Option<usize>,
) -> String {
let agent_role_guidance = available_models_description.unwrap_or_default();
let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();
let concurrency_guidance = max_concurrent_threads_per_session
.map(|limit| {
format!(
"This session is configured with `max_concurrent_threads_per_session = {limit}` for concurrently open agent threads."
)
})
.unwrap_or_default();
let tool_description = format!(
r#"
@@ -743,8 +733,7 @@ The spawned agent will have the same tools as you and the ability to spawn its o
{inherited_model_guidance}
Only call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally.
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
The new agent's canonical task name will be provided to it along with the message.
{concurrency_guidance}"#
The new agent's canonical task name will be provided to it along with the message."#
);
if !include_usage_hint {
@@ -47,7 +47,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool {
@@ -69,7 +68,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
.expect("spawn_agent should use object params");
assert!(description.contains("Spawns an agent to work on the specified task."));
assert!(description.contains("The spawned agent will have the same tools as you"));
assert!(description.contains("`max_concurrent_threads_per_session = 4`"));
assert!(!description.contains("max_concurrent_threads_per_session"));
assert!(description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE));
assert!(
description
@@ -124,7 +123,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: None,
});
let ToolSpec::Namespace(namespace) = tool else {
@@ -182,7 +180,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
@@ -227,7 +224,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
hide_agent_type_model_reasoning: true,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool {
-15
View File
@@ -388,15 +388,6 @@ fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOpt
}
}
fn max_concurrent_threads_per_session(turn_context: &TurnContext) -> Option<usize> {
multi_agent_v2_enabled(turn_context).then_some(
turn_context
.config
.multi_agent_v2
.max_concurrent_threads_per_session,
)
}
fn agent_type_description(
turn_context: &TurnContext,
default_agent_type_description: &str,
@@ -729,9 +720,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
.hide_spawn_agent_metadata,
include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled,
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
max_concurrent_threads_per_session: max_concurrent_threads_per_session(
turn_context,
),
}),
tool_namespace,
),
@@ -776,9 +764,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
hide_agent_type_model_reasoning: false,
include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled,
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
max_concurrent_threads_per_session: max_concurrent_threads_per_session(
turn_context,
),
}),
exposure,
);
+1 -1
View File
@@ -1015,7 +1015,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
ToolSpec::Function(tool) => tool.description.as_str(),
other => panic!("expected spawn_agent function spec, got {other:?}"),
};
assert!(spawn_agent_description.contains("max_concurrent_threads_per_session = 17"));
assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session"));
let direct_model_only = probe(|turn| {
set_features(