diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dc83fc599..4ce6ac1a8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d88c2c9d4..87726d30c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 3c6a77442..0c2f2f54b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index b1c72bb1f..9460f8068 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts new file mode 100644 index 000000000..93b790a59 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index f7db65d5d..7c94d9c60 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 459b11f7f..96cbb1ed2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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(¶ms), + 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!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 77da0ea8a..7ecb9f94b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -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, + + /// 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, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f4f3bbc3b..5914c3a85 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 agent’s 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 thread’s 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"`. diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 771b1fd12..7c4ae8c98 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -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), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 3e336cc2a..0ac9b7fa2 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -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, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index b7408a8c3..c6a739afc 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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, summary: Option, collaboration_mode: Option, + multi_agent_mode: Option, personality: Option, } @@ -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, }, ) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index f577a01fd..058674653 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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::(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("") + && 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( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6eff34208..7d21c9069 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ab46d8b3a..0f63323d4 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -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, pub personality: Option, pub collaboration_mode: CollaborationMode, + pub multi_agent_mode: Option, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, @@ -151,6 +153,7 @@ pub struct CodexThreadSettingsOverrides { pub summary: Option, pub service_tier: Option>, pub collaboration_mode: Option, + pub multi_agent_mode: Option, pub personality: Option, } @@ -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, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 53a31433a..a6a9ab636 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1089ba648..4e0cb77fe 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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." ) } diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 1204a1d16..d40ce4e67 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -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, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3c2f48723..3347358bb 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -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; diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs new file mode 100644 index 000000000..a272e7c2b --- /dev/null +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -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(), + } + } +} diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 8b9609a25..35a9656fe 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -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("ROLLED_BACK_DEV_INSTRUCTIONS"), + developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( "PRETURN_CONTEXT_DIFF_CWD", ), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 4c69aae95..f3becabc6 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -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 { + 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), ] diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 73193984b..1ca097e78 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -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] = &[ "", "", COLLABORATION_MODE_OPEN_TAG, + MULTI_AGENT_MODE_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5299747f3..98aef2572 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -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, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fbd5a61bc..8b7a15ff0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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) { diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index a78a9c938..38651380e 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -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, + multi_agent_mode_enabled: bool, +) -> Option { + 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 + }) +} diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bbdfc3f98..7fccc4d19 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -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(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0b551ce72..43563d233 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -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, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1e8c250bc..0c07f5e95 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,6 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, + pub(super) multi_agent_mode: Option, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -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, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, + pub(crate) multi_agent_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e7532a1e5..6f8215530 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c03bdab7c..18d56cdfe 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -125,6 +125,7 @@ pub struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, + pub(crate) multi_agent_mode: Option, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, @@ -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(), diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f6e84f5ca..800b52c77 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -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; diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs new file mode 100644 index 000000000..ba91f2fd3 --- /dev/null +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -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, +) -> 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::>(); + 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(()) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0b9ff204d..073e6d8b4 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -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 diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 04f0029fa..a04e25855 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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", diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 8483ef97b..6dc8827fe 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -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", diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 713945dbd..64cfefeb7 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -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, )] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 99aa0a0b6..bc0186bc6 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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 = ""; pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; +pub const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; +pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; 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, + /// Updated multi-agent mode for this turn and subsequent turns. + pub multi_agent_mode: Option, + /// Updated personality preference. pub personality: Option, } @@ -2548,6 +2554,14 @@ impl InitialHistory { } } + pub fn get_multi_agent_mode(&self) -> Option { + 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)> { 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 { + 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_version: Option, + /// Effective model-visible mode used as the durable context-diff baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub realtime_active: Option, #[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, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2558750f1..8701578eb 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -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, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 4d25965c4..e156cc911 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -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, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a3734ac0e..365ee92fc 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -786,6 +786,7 @@ impl AppServerSession { personality, output_schema, collaboration_mode, + multi_agent_mode: None, }, }) .await