Add per-turn multi-agent mode (#28685)

## Why

Multi-agent v2 currently carries an explicit-request-only delegation
rule in its static usage hint. That provides a safe default, but it
prevents clients from selecting proactive delegation per turn without
changing static guidance or rewriting prior model context.

This change makes delegation mode a session selection that can be
updated through `turn/start`, while deriving the effective model-visible
mode separately for each turn. Eligible multi-agent v2 turns remain
explicit-request-only unless proactive mode is both selected and
enabled.

## What changed

- Add the experimental `turn/start.multiAgentMode` parameter with
`explicitRequestOnly` and `proactive` values. Omission retains the
loaded session's current optional selection.
- Add the default-off `features.multi_agent_mode` feature gate. Eligible
multi-agent v2 turns use the selected mode when enabled; an unset
selection or disabled gate resolves to `explicitRequestOnly`.
- Treat mode prompting as inapplicable for multi-agent v1 and other
unsupported session configurations, producing no multi-agent mode
developer message rather than rejecting the turn.
- Move the explicit-request-only rule out of the static v2 usage hint
and into a bounded, tagged developer context fragment.
- Emit the effective mode in initial context and only when that
effective mode changes on later turns.
- Persist the effective mode in `TurnContextItem` as the durable
baseline for resume and context-update comparisons.

Historical rollout items are not rewritten. Later mode developer
messages establish the current rule incrementally.

## Not covered

- Initial selection through `thread/start` and selected-mode reporting
from thread lifecycle/settings APIs; those are isolated in the stacked
#28792.
- A TUI control or slash command for selecting the mode.
- Persisting a preferred mode to `config.toml`; selection remains
session/turn scoped.
- Changes to multi-agent concurrency limits, tool availability, or model
catalog capability declarations.
- Rewriting historical rollout prompt items. Cold resume restores the
latest persisted effective mode when available while leaving historical
developer messages intact.

## Verification

- `CARGO_INCREMENTAL=0 just test -p codex-core multi_agent_mode`
- Focused app-server coverage verifies that `turn/start.multiAgentMode`
produces proactive developer instructions for an eligible v2 turn.

## Stack

Followed by #28792, which adds `thread/start` initialization and
lifecycle/settings observability.
This commit is contained in:
Shijie Rao
2026-06-18 22:47:51 -07:00
committed by GitHub
Unverified
parent f886e33e5a
commit fc8c6b7384
41 changed files with 779 additions and 8 deletions
@@ -1760,6 +1760,14 @@
"ModelProviderCapabilitiesReadParams": {
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.",
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -12637,6 +12637,14 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.",
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -9061,6 +9061,14 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.",
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -141,6 +141,14 @@
],
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.",
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Controls whether the model should only spawn sub-agents after an explicit
* user request or may delegate proactively when doing so would help.
*/
export type MultiAgentMode = "explicitRequestOnly" | "proactive";
+1
View File
@@ -49,6 +49,7 @@ export type { LocalShellStatus } from "./LocalShellStatus";
export type { McpServerInfo } from "./McpServerInfo";
export type { MessagePhase } from "./MessagePhase";
export type { ModeKind } from "./ModeKind";
export type { MultiAgentMode } from "./MultiAgentMode";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { ParsedCommand } from "./ParsedCommand";
@@ -3774,6 +3774,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
summary: None,
output_schema: None,
collaboration_mode: None,
multi_agent_mode: None,
personality: None,
};
let serialized_without_override =
@@ -3781,6 +3782,29 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn turn_start_params_round_trip_multi_agent_mode() {
let params: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread_123",
"input": [],
"multiAgentMode": "proactive"
}))
.expect("params should deserialize");
assert_eq!(
params.multi_agent_mode,
Some(codex_protocol::config_types::MultiAgentMode::Proactive)
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&params),
Some("turn/start.multiAgentMode")
);
assert_eq!(
serde_json::to_value(params).expect("params should serialize")["multiAgentMode"],
"proactive"
);
}
#[test]
fn thread_settings_update_params_preserve_explicit_null_service_tier() {
let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({
@@ -4,6 +4,7 @@ use super::SandboxPolicy;
use super::Turn;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ImageDetail;
@@ -149,6 +150,12 @@ pub struct TurnStartParams {
#[experimental("turn/start.collaborationMode")]
#[ts(optional = nullable)]
pub collaboration_mode: Option<CollaborationMode>,
/// Controls whether multi-agent v2 delegation requires an explicit user request.
/// Omitted keeps the loaded session's current mode.
#[experimental("turn/start.multiAgentMode")]
#[ts(optional = nullable)]
pub multi_agent_mode: Option<MultiAgentMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+1 -1
View File
@@ -168,7 +168,7 @@ Example with notification opt-out:
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
- `thread/rollback` — drop the last N turns from the agents in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns use the requested mode when `features.multi_agent_mode` is enabled and otherwise use explicit-request-only developer instructions.
- `thread/inject_items` — append raw Responses API items to a loaded threads model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
@@ -673,6 +673,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
personality: None,
output_schema: None,
collaboration_mode: None,
multi_agent_mode: None,
},
},
Some(remote_trace),
@@ -776,6 +776,7 @@ mod thread_processor_behavior_tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
session_source: SessionSource::Cli,
forked_from_thread_id: None,
parent_thread_id: None,
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
use codex_protocol::protocol::MultiAgentVersion;
@@ -60,6 +61,7 @@ struct ThreadSettingsBuildParams {
effort: Option<ReasoningEffort>,
summary: Option<ReasoningSummary>,
collaboration_mode: Option<CollaborationMode>,
multi_agent_mode: Option<MultiAgentMode>,
personality: Option<Personality>,
}
@@ -454,6 +456,7 @@ impl TurnRequestProcessor {
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
multi_agent_mode: params.multi_agent_mode,
personality: params.personality,
},
)
@@ -560,6 +563,7 @@ impl TurnRequestProcessor {
effort,
summary,
collaboration_mode,
multi_agent_mode,
personality,
} = params;
@@ -593,6 +597,7 @@ impl TurnRequestProcessor {
|| effort.is_some()
|| summary.is_some()
|| collaboration_mode.is_some()
|| multi_agent_mode.is_some()
|| personality.is_some();
let runtime_workspace_roots =
@@ -669,6 +674,7 @@ impl TurnRequestProcessor {
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
multi_agent_mode,
personality,
})
.await
@@ -692,6 +698,7 @@ impl TurnRequestProcessor {
summary,
service_tier,
collaboration_mode,
multi_agent_mode,
personality,
})
}
@@ -722,6 +729,7 @@ impl TurnRequestProcessor {
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
multi_agent_mode: None,
personality: params.personality,
},
)
@@ -69,6 +69,7 @@ use codex_features::FEATURES;
use codex_features::Feature;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
@@ -1749,6 +1750,83 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::from([
(Feature::MultiAgentV2, true),
(Feature::MultiAgentMode, true),
]),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _: TurnStartResponse = to_response(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let developer_texts = response_mock
.single_request()
.message_input_texts("developer");
assert!(developer_texts.iter().any(|text| {
text.contains("<multi_agent_mode>")
&& text.contains("Proactive multi-agent delegation is active.")
}));
assert!(!developer_texts.iter().any(|text| {
text.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")
}));
Ok(())
}
#[tokio::test]
async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2367,6 +2445,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
personality: None,
output_schema: None,
collaboration_mode: None,
multi_agent_mode: None,
})
.await?;
timeout(
@@ -2406,6 +2485,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
personality: None,
output_schema: None,
collaboration_mode: None,
multi_agent_mode: None,
})
.await?;
timeout(
+6
View File
@@ -557,6 +557,9 @@
"multi_agent": {
"type": "boolean"
},
"multi_agent_mode": {
"type": "boolean"
},
"multi_agent_v2": {
"$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml"
},
@@ -4793,6 +4796,9 @@
"multi_agent": {
"type": "boolean"
},
"multi_agent_mode": {
"type": "boolean"
},
"multi_agent_v2": {
"$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml"
},
+5
View File
@@ -8,6 +8,7 @@ use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -71,6 +72,7 @@ pub struct ThreadConfigSnapshot {
pub reasoning_summary: Option<ReasoningSummary>,
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
pub multi_agent_mode: Option<MultiAgentMode>,
pub session_source: SessionSource,
pub forked_from_thread_id: Option<ThreadId>,
pub parent_thread_id: Option<ThreadId>,
@@ -151,6 +153,7 @@ pub struct CodexThreadSettingsOverrides {
pub summary: Option<ReasoningSummary>,
pub service_tier: Option<Option<String>>,
pub collaboration_mode: Option<CollaborationMode>,
pub multi_agent_mode: Option<MultiAgentMode>,
pub personality: Option<Personality>,
}
@@ -371,6 +374,7 @@ impl CodexThread {
summary,
service_tier,
collaboration_mode,
multi_agent_mode,
personality,
} = overrides;
let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode {
@@ -394,6 +398,7 @@ impl CodexThread {
active_permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
multi_agent_mode,
reasoning_summary: summary,
service_tier,
personality,
+2 -3
View File
@@ -10055,9 +10055,8 @@ max_concurrent_threads_per_session = 17
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.";
let expected_suffix = format!(
"{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}"
);
let expected_suffix =
format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}");
assert!(
[
config.root_agent_usage_hint_text,
+1 -3
View File
@@ -247,11 +247,9 @@ All agents share the same directory. In detail:
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
"#;
const DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work.";
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
format!(
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you.\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}"
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
)
}
@@ -204,6 +204,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
+2
View File
@@ -17,6 +17,7 @@ mod legacy_apply_patch_exec_command_warning;
mod legacy_model_mismatch_warning;
mod legacy_unified_exec_process_limit_warning;
mod model_switch_instructions;
mod multi_agent_mode_instructions;
mod network_rule_saved;
mod permissions_instructions;
mod personality_spec_instructions;
@@ -59,6 +60,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm
pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning;
pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning;
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions;
pub(crate) use network_rule_saved::NetworkRuleSaved;
pub use permissions_instructions::PermissionsInstructions;
pub(crate) use personality_spec_instructions::PersonalitySpecInstructions;
@@ -0,0 +1,41 @@
use super::ContextualUserFragment;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work.";
const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MultiAgentModeInstructions {
multi_agent_mode: MultiAgentMode,
}
impl MultiAgentModeInstructions {
pub(crate) fn new(multi_agent_mode: MultiAgentMode) -> Self {
Self { multi_agent_mode }
}
}
impl ContextualUserFragment for MultiAgentModeInstructions {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(MULTI_AGENT_MODE_OPEN_TAG, MULTI_AGENT_MODE_CLOSE_TAG)
}
fn body(&self) -> String {
match self.multi_agent_mode {
MultiAgentMode::ExplicitRequestOnly => {
EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string()
}
MultiAgentMode::Proactive => PROACTIVE_MULTI_AGENT_MODE_TEXT.to_string(),
}
}
}
@@ -146,6 +146,7 @@ fn reference_context_item() -> TurnContextItem {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(false),
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -935,6 +936,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
developer_msg("<multi_agent_mode>ROLLED_BACK_MULTI_AGENT_MODE</multi_agent_mode>"),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
@@ -2,6 +2,7 @@ use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
use crate::context::EnvironmentContext;
use crate::context::ModelSwitchInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::context::RealtimeEndInstructions;
@@ -12,6 +13,7 @@ use crate::session::turn_context::TurnContext;
use crate::shell::Shell;
use codex_execpolicy::Policy;
use codex_features::Feature;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -95,6 +97,31 @@ fn build_collaboration_mode_update_item(
}
}
fn build_multi_agent_mode_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
) -> Option<String> {
let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode(
next.multi_agent_version,
&next.config.multi_agent_v2,
&next.session_source,
next.multi_agent_mode,
next.features.enabled(Feature::MultiAgentMode),
);
let previous = previous?;
if previous.multi_agent_mode == effective_multi_agent_mode {
return None;
}
match effective_multi_agent_mode {
Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()),
None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => {
Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render())
}
None => None,
}
}
pub(crate) fn build_realtime_update_item(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
@@ -230,6 +257,7 @@ pub(crate) fn build_settings_update_items(
build_model_instructions_update_item(previous_turn_settings, next),
build_permissions_update_item(previous, next, exec_policy),
build_collaboration_mode_update_item(previous, next),
build_multi_agent_mode_update_item(previous, next),
build_realtime_update_item(previous, previous_turn_settings, next),
build_personality_update_item(previous, next, personality_feature_enabled),
]
+2
View File
@@ -15,6 +15,7 @@ use codex_protocol::models::is_image_open_tag_text;
use codex_protocol::models::is_local_image_close_tag_text;
use codex_protocol::models::is_local_image_open_tag_text;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::user_input::UserInput;
@@ -29,6 +30,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<permissions instructions>",
"<model_switch>",
COLLABORATION_MODE_OPEN_TAG,
MULTI_AGENT_MODE_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
"<personality_spec>",
+2
View File
@@ -124,6 +124,7 @@ async fn thread_settings_update(
summary,
service_tier,
collaboration_mode,
multi_agent_mode,
personality,
} = thread_settings;
let collaboration_mode = match collaboration_mode {
@@ -149,6 +150,7 @@ async fn thread_settings_update(
active_permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
multi_agent_mode,
reasoning_summary: summary,
service_tier,
personality,
+16 -1
View File
@@ -27,6 +27,7 @@ use crate::context::AvailablePluginsInstructions;
use crate::context::AvailableSkillsInstructions;
use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
use crate::context::MultiAgentModeInstructions;
use crate::context::NetworkRuleSaved;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
@@ -92,6 +93,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
@@ -210,7 +212,7 @@ mod handlers;
mod inject;
mod input_queue;
mod mcp;
mod multi_agents;
pub(crate) mod multi_agents;
mod review;
mod rollout_budget;
mod rollout_reconstruction;
@@ -581,6 +583,7 @@ impl Codex {
.await;
let multi_agent_version =
resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version);
let multi_agent_mode = conversation_history.get_multi_agent_mode();
config
.validate_multi_agent_v2_config()
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
@@ -614,6 +617,7 @@ impl Codex {
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode,
model_reasoning_summary: config.model_reasoning_summary,
service_tier,
developer_instructions: config.developer_instructions.clone(),
@@ -3240,6 +3244,17 @@ impl Session {
{
items.push(usage_hint_message);
}
if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode(
turn_context.multi_agent_version,
&turn_context.config.multi_agent_v2,
&session_source,
turn_context.multi_agent_mode,
turn_context.features.enabled(Feature::MultiAgentMode),
) {
items.push(ContextualUserFragment::into(
MultiAgentModeInstructions::new(multi_agent_mode),
));
}
if let Some(contextual_user_message) =
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
{
+37
View File
@@ -1,4 +1,6 @@
use crate::config::MultiAgentV2Config;
use crate::session::turn_context::TurnContext;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -16,6 +18,13 @@ pub(super) fn usage_hint_text<'a>(
return None;
}
configured_usage_hint_text_for_source(multi_agent_v2, session_source)
}
fn configured_usage_hint_text_for_source<'a>(
multi_agent_v2: &'a MultiAgentV2Config,
session_source: &SessionSource,
) -> Option<&'a str> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => {
multi_agent_v2.subagent_usage_hint_text.as_deref()
@@ -29,3 +38,31 @@ pub(super) fn usage_hint_text<'a>(
SessionSource::Internal(_) | SessionSource::SubAgent(_) => None,
}
}
fn multi_agent_mode_is_applicable(
multi_agent_version: MultiAgentVersion,
multi_agent_v2: &MultiAgentV2Config,
session_source: &SessionSource,
) -> bool {
multi_agent_version == MultiAgentVersion::V2
&& multi_agent_v2.usage_hint_enabled
&& configured_usage_hint_text_for_source(multi_agent_v2, session_source).is_some()
}
pub(crate) fn effective_multi_agent_mode(
multi_agent_version: MultiAgentVersion,
multi_agent_v2: &MultiAgentV2Config,
session_source: &SessionSource,
requested_multi_agent_mode: Option<MultiAgentMode>,
multi_agent_mode_enabled: bool,
) -> Option<MultiAgentMode> {
if !multi_agent_mode_is_applicable(multi_agent_version, multi_agent_v2, session_source) {
return None;
}
Some(if multi_agent_mode_enabled {
requested_multi_agent_mode.unwrap_or_default()
} else {
MultiAgentMode::ExplicitRequestOnly
})
}
+1
View File
@@ -123,6 +123,7 @@ pub(super) async fn spawn_review_thread(
developer_instructions: None,
user_instructions: None,
collaboration_mode: parent_turn_context.collaboration_mode.clone(),
multi_agent_mode: parent_turn_context.multi_agent_mode,
multi_agent_version: MultiAgentVersion::Disabled,
personality: parent_turn_context.personality,
approval_policy: parent_turn_context.approval_policy.clone(),
@@ -102,6 +102,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -142,6 +143,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1007,6 +1009,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1090,6 +1093,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1120,6 +1124,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1243,6 +1248,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1363,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -1527,6 +1534,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
+6
View File
@@ -52,6 +52,7 @@ pub(crate) struct SessionConfiguration {
pub(super) provider: ModelProviderInfo,
pub(super) collaboration_mode: CollaborationMode,
pub(super) multi_agent_mode: Option<MultiAgentMode>,
pub(super) model_reasoning_summary: Option<ReasoningSummaryConfig>,
pub(super) service_tier: Option<String>,
@@ -192,6 +193,7 @@ impl SessionConfiguration {
reasoning_summary: self.model_reasoning_summary,
personality: self.personality,
collaboration_mode: self.collaboration_mode.clone(),
multi_agent_mode: self.multi_agent_mode,
session_source: self.session_source.clone(),
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
@@ -228,6 +230,9 @@ impl SessionConfiguration {
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
next_configuration.collaboration_mode = collaboration_mode;
}
if let Some(multi_agent_mode) = updates.multi_agent_mode {
next_configuration.multi_agent_mode = Some(multi_agent_mode);
}
if let Some(summary) = updates.reasoning_summary {
next_configuration.model_reasoning_summary = Some(summary);
}
@@ -422,6 +427,7 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) active_permission_profile: Option<ActivePermissionProfile>,
pub(crate) windows_sandbox_level: Option<WindowsSandboxLevel>,
pub(crate) collaboration_mode: Option<CollaborationMode>,
pub(crate) multi_agent_mode: Option<MultiAgentMode>,
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) service_tier: Option<Option<String>>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
+9
View File
@@ -2732,6 +2732,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -3349,6 +3350,7 @@ async fn set_rate_limits_retains_previous_credits() {
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -3455,6 +3457,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -3982,6 +3985,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -4848,6 +4852,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -4960,6 +4965,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -5205,6 +5211,7 @@ async fn make_session_with_config_and_rx(
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -5311,6 +5318,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
@@ -7017,6 +7025,7 @@ where
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
multi_agent_mode: Default::default(),
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
loaded_agents_md: None,
+10
View File
@@ -125,6 +125,7 @@ pub struct TurnContext {
pub(crate) developer_instructions: Option<String>,
pub(crate) user_instructions: Option<String>,
pub(crate) collaboration_mode: CollaborationMode,
pub(crate) multi_agent_mode: Option<MultiAgentMode>,
pub(crate) multi_agent_version: MultiAgentVersion,
pub(crate) personality: Option<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
@@ -276,6 +277,7 @@ impl TurnContext {
developer_instructions: self.developer_instructions.clone(),
user_instructions: self.user_instructions.clone(),
collaboration_mode,
multi_agent_mode: self.multi_agent_mode,
multi_agent_version: self.multi_agent_version,
personality: self.personality,
approval_policy: self.approval_policy.clone(),
@@ -381,6 +383,13 @@ impl TurnContext {
personality: self.personality,
collaboration_mode: Some(self.collaboration_mode.clone()),
multi_agent_version: Some(self.multi_agent_version),
multi_agent_mode: super::multi_agents::effective_multi_agent_mode(
self.multi_agent_version,
&self.config.multi_agent_v2,
&self.session_source,
self.multi_agent_mode,
self.features.enabled(Feature::MultiAgentMode),
),
realtime_active: Some(self.realtime_active),
effort: self.reasoning_effort.clone(),
summary: ReasoningSummaryConfig::Auto,
@@ -562,6 +571,7 @@ impl Session {
.as_ref()
.map(LoadedAgentsMd::render),
collaboration_mode: session_configuration.collaboration_mode.clone(),
multi_agent_mode: session_configuration.multi_agent_mode,
multi_agent_version,
personality: session_configuration.personality,
approval_policy: session_configuration.approval_policy.clone(),
+1
View File
@@ -73,6 +73,7 @@ mod model_switching;
mod model_visible_layout;
mod models_cache_ttl;
mod models_etag_responses;
mod multi_agent_mode;
mod openai_file_mcp;
mod otel;
mod override_updates;
@@ -0,0 +1,376 @@
use anyhow::Result;
use codex_features::Feature;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work.";
const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active.";
fn developer_texts(input: &[Value]) -> Vec<&str> {
input
.iter()
.filter(|item| item.get("role").and_then(Value::as_str) == Some("developer"))
.filter_map(|item| item.get("content")?.as_array())
.flatten()
.filter_map(|content| content.get("text")?.as_str())
.collect()
}
fn count_containing(texts: &[&str], target: &str) -> usize {
texts.iter().filter(|text| text.contains(target)).count()
}
async fn submit_turn(
codex: &codex_core::CodexThread,
prompt: &str,
mode: Option<MultiAgentMode>,
) -> Result<()> {
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: prompt.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides {
multi_agent_mode: mode,
..Default::default()
},
})
.await?;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
(1..=3)
.map(|index| {
sse(vec![
ev_response_created(&format!("resp-{index}")),
ev_completed(&format!("resp-{index}")),
])
})
.collect(),
)
.await;
let test = test_codex()
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentMode)
.expect("test config should allow feature update");
})
.build(&server)
.await?;
submit_turn(&test.codex, "turn one", /*mode*/ None).await?;
assert_eq!(test.codex.config_snapshot().await.multi_agent_mode, None);
submit_turn(&test.codex, "turn two", Some(MultiAgentMode::Proactive)).await?;
submit_turn(&test.codex, "turn three", /*mode*/ None).await?;
let requests = responses.requests();
let inputs = requests
.iter()
.map(core_test_support::responses::ResponsesRequest::input)
.collect::<Vec<_>>();
let first = developer_texts(&inputs[0]);
let second = developer_texts(&inputs[1]);
let third = developer_texts(&inputs[2]);
assert_eq!(
(
count_containing(&first, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&first, NO_SPAWN_TEXT),
count_containing(&first, PROACTIVE_TEXT),
),
(1, 1, 0)
);
assert_eq!(
(
count_containing(&second, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&second, NO_SPAWN_TEXT),
count_containing(&second, PROACTIVE_TEXT),
),
(2, 1, 1)
);
assert_eq!(
(
count_containing(&third, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&third, NO_SPAWN_TEXT),
count_containing(&third, PROACTIVE_TEXT),
),
(2, 1, 1)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let test = test_codex()
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
})
.build(&server)
.await?;
submit_turn(&test.codex, "hello", /*mode*/ None).await?;
let input = responses.single_request().input();
let texts = developer_texts(&input);
assert_eq!(
(
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&texts, NO_SPAWN_TEXT),
count_containing(&texts, PROACTIVE_TEXT),
),
(1, 1, 0)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
(1..=4)
.map(|index| {
sse(vec![
ev_response_created(&format!("resp-{index}")),
ev_completed(&format!("resp-{index}")),
])
})
.collect(),
)
.await;
let initial = test_codex()
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
})
.build(&server)
.await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
submit_turn(
&initial.codex,
"before resume",
Some(MultiAgentMode::Proactive),
)
.await?;
drop(initial);
let mut resume_builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentMode)
.expect("test config should allow feature update");
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
submit_turn(
&resumed.codex,
"after resume",
Some(MultiAgentMode::Proactive),
)
.await?;
let requests = responses.requests();
let resumed_input = requests[1].input();
let texts = developer_texts(&resumed_input);
assert_eq!(
(
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&texts, NO_SPAWN_TEXT),
count_containing(&texts, PROACTIVE_TEXT),
),
(2, 1, 1)
);
let resumed_rollout_path = resumed
.session_configured
.rollout_path
.clone()
.expect("resumed rollout path");
let resumed_home = resumed.home.clone();
drop(resumed);
let mut same_mode_resume_builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentMode)
.expect("test config should allow feature update");
});
let resumed_same_mode = same_mode_resume_builder
.resume(&server, resumed_home, resumed_rollout_path)
.await?;
submit_turn(
&resumed_same_mode.codex,
"after same-mode resume",
/*mode*/ None,
)
.await?;
assert_eq!(
resumed_same_mode
.codex
.config_snapshot()
.await
.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let requests = responses.requests();
let resumed_same_mode_input = requests[2].input();
let texts = developer_texts(&resumed_same_mode_input);
assert_eq!(
(
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&texts, NO_SPAWN_TEXT),
count_containing(&texts, PROACTIVE_TEXT),
),
(2, 1, 1)
);
let resumed_same_mode_rollout_path = resumed_same_mode
.session_configured
.rollout_path
.clone()
.expect("same-mode resumed rollout path");
let resumed_same_mode_home = resumed_same_mode.home.clone();
drop(resumed_same_mode);
let mut disabled_mode_resume_builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
});
let resumed_disabled_mode = disabled_mode_resume_builder
.resume(
&server,
resumed_same_mode_home,
resumed_same_mode_rollout_path,
)
.await?;
submit_turn(
&resumed_disabled_mode.codex,
"after disabled-mode resume",
/*mode*/ None,
)
.await?;
assert_eq!(
resumed_disabled_mode
.codex
.config_snapshot()
.await
.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let requests = responses.requests();
let resumed_disabled_mode_input = requests[3].input();
let texts = developer_texts(&resumed_disabled_mode_input);
assert_eq!(
(
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&texts, NO_SPAWN_TEXT),
count_containing(&texts, PROACTIVE_TEXT),
),
(3, 2, 1)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let test = test_codex()
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentMode)
.expect("test config should allow feature update");
})
.build(&server)
.await?;
submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?;
assert_eq!(
test.codex.config_snapshot().await.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let input = responses.single_request().input();
let texts = developer_texts(&input);
assert_eq!(
(
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
count_containing(&texts, PROACTIVE_TEXT),
),
(0, 0)
);
Ok(())
}
@@ -41,6 +41,7 @@ fn resume_history(
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: config.model_reasoning_effort.clone(),
summary: config
+1
View File
@@ -891,6 +891,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
personality: None,
output_schema,
collaboration_mode: None,
multi_agent_mode: None,
},
},
"turn/start",
+8
View File
@@ -144,6 +144,8 @@ pub enum Feature {
Collab,
/// Enable task-path-based multi-agent routing.
MultiAgentV2,
/// Enable per-turn multi-agent mode selection.
MultiAgentMode,
/// Enable CSV-backed agent job tools.
SpawnCsv,
/// Enable apps.
@@ -1018,6 +1020,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::MultiAgentMode,
key: "multi_agent_mode",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::SpawnCsv,
key: "enable_fanout",
+14
View File
@@ -296,6 +296,20 @@ pub enum Personality {
Pragmatic,
}
/// Controls whether the model should only spawn sub-agents after an explicit
/// user request or may delegate proactively when doing so would help.
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum MultiAgentMode {
#[default]
ExplicitRequestOnly,
Proactive,
}
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
)]
+29
View File
@@ -21,6 +21,7 @@ use crate::approvals::ElicitationRequestEvent;
use crate::config_types::ApprovalsReviewer;
use crate::config_types::CollaborationMode;
use crate::config_types::ModeKind;
use crate::config_types::MultiAgentMode;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::config_types::WindowsSandboxLevel;
@@ -104,6 +105,8 @@ pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = "<plugins_instructions>";
pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = "</plugins_instructions>";
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
pub const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";
pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = "</multi_agent_mode>";
pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
@@ -479,6 +482,9 @@ pub struct ThreadSettingsOverrides {
/// Takes precedence over model, effort, and developer instructions if set.
pub collaboration_mode: Option<CollaborationMode>,
/// Updated multi-agent mode for this turn and subsequent turns.
pub multi_agent_mode: Option<MultiAgentMode>,
/// Updated personality preference.
pub personality: Option<Personality>,
}
@@ -2548,6 +2554,14 @@ impl InitialHistory {
}
}
pub fn get_multi_agent_mode(&self) -> Option<MultiAgentMode> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history),
InitialHistory::Forked(items) => multi_agent_mode_from_items(items),
}
}
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
let meta = self.get_resumed_session_meta()?;
Some((meta.source.clone(), meta.thread_source.clone()))
@@ -2860,6 +2874,17 @@ fn multi_agent_version_from_items(
})
}
fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option<MultiAgentMode> {
items.iter().rev().find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode,
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_) => None,
})
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -3027,6 +3052,9 @@ pub struct TurnContextItem {
pub collaboration_mode: Option<CollaborationMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_agent_version: Option<MultiAgentVersion>,
/// Effective model-visible mode used as the durable context-diff baseline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_agent_mode: Option<MultiAgentMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realtime_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -5399,6 +5427,7 @@ mod tests {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: None,
summary: ReasoningSummaryConfig::Auto,
+1
View File
@@ -1152,6 +1152,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
+4
View File
@@ -368,6 +368,7 @@ mod tests {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -412,6 +413,7 @@ mod tests {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -452,6 +454,7 @@ mod tests {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: Some(ReasoningEffort::High),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -489,6 +492,7 @@ mod tests {
personality: None,
collaboration_mode: None,
multi_agent_version: None,
multi_agent_mode: None,
realtime_active: None,
effort: Some(ReasoningEffort::High),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
+1
View File
@@ -786,6 +786,7 @@ impl AppServerSession {
personality,
output_schema,
collaboration_mode,
multi_agent_mode: None,
},
})
.await