Expose thread-level multi-agent mode (#28792)

## Why

Once multi-agent mode can be selected per turn, clients also need to
choose the initial selection when creating a thread and observe that
selection through lifecycle and settings APIs.

The selected value is intentionally distinct from the effective
model-visible value: no client selection is represented as `null`, even
though an eligible multi-agent v2 turn derives `explicitRequestOnly` as
its effective default.

## What changed

- Add the optional experimental `thread/start.multiAgentMode` parameter
and pass it through thread creation.
- Preserve an omitted initial value as an unset selection rather than
eagerly storing `explicitRequestOnly`.
- Apply an explicit `thread/start` selection to the first turn through
the session configuration established at thread creation.
- Restore the latest persisted effective mode as the selected baseline
on cold resume when rollout history contains one.
- Inherit the optional selected mode from a loaded parent when creating
related runtime threads.
- Return the current selected `multiAgentMode` from `thread/start`,
`thread/resume`, `thread/fork`, and thread settings, using `null` when
no mode is selected.
- Keep lifecycle reporting independent from model capability and feature
eligibility; core turn construction remains responsible for calculating
and persisting the effective mode.

## Not covered

- Clearing an existing loaded-session selection back to unset through
`turn/start`; omitted or `null` currently retains the session's
selection.
- A TUI control, slash command, or `config.toml` preference.

## Verification

- `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol`
- `CARGO_INCREMENTAL=0 just test -p codex-app-server multi_agent_mode`

The focused app-server coverage verifies explicit `thread/start`
initialization, first-turn prompting, nullable reporting for an omitted
selection, and retention of selections that are not currently
runtime-eligible.

## Stack

Stacked on #28685. This PR contains only the thread initialization and
lifecycle/settings API layer.
This commit is contained in:
Shijie Rao
2026-06-19 01:50:44 -07:00
committed by GitHub
Unverified
parent fc8c6b7384
commit 7abfcf220b
46 changed files with 620 additions and 36 deletions
@@ -224,6 +224,7 @@ fn sample_thread_start_response(
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
})
}
@@ -288,6 +289,7 @@ fn sample_thread_resume_response_with_source(
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
initial_turns_page: None,
})
}
+3
View File
@@ -314,6 +314,7 @@ fn sample_thread_start_response() -> ClientResponsePayload {
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
})
}
@@ -331,6 +332,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload {
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
initial_turns_page: None,
})
}
@@ -349,6 +351,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload {
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
})
}
@@ -2611,6 +2611,14 @@
],
"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",
@@ -662,6 +662,14 @@
}
]
},
"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",
@@ -662,6 +662,14 @@
}
]
},
"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",
@@ -108,6 +108,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",
@@ -194,6 +194,14 @@
"LegacyAppPathString": {
"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"
},
"Personality": {
"enum": [
"none",
@@ -662,6 +662,14 @@
}
]
},
"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",
@@ -11,4 +11,4 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, };
export type ThreadSettings = {cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null};
@@ -2583,6 +2583,7 @@ mod tests {
sandbox: v2::SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: None,
},
};
@@ -2630,7 +2631,8 @@ mod tests {
"type": "dangerFullAccess"
},
"activePermissionProfile": null,
"reasoningEffort": null
"reasoningEffort": null,
"multiAgentMode": null
}
}),
serde_json::to_value(&response)?,
@@ -3545,6 +3547,7 @@ mod tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
personality: None,
},
});
@@ -199,6 +199,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
sandbox: SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
initial_turns_page: Some(TurnsPage {
data: Vec::new(),
next_cursor: Some("cursor_next".to_string()),
@@ -3701,6 +3702,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(resume.active_permission_profile, None);
assert_eq!(resume.initial_turns_page, None);
assert_eq!(fork.active_permission_profile, None);
assert_eq!(
(
start.multi_agent_mode,
resume.multi_agent_mode,
fork.multi_agent_mode,
),
(None, None, None)
);
let foreign_source: LegacyAppPathString =
serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source");
@@ -3805,6 +3814,27 @@ fn turn_start_params_round_trip_multi_agent_mode() {
);
}
#[test]
fn thread_start_params_round_trip_multi_agent_mode() {
let params: ThreadStartParams = serde_json::from_value(json!({
"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("thread/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!({
@@ -14,6 +14,7 @@ use codex_experimental_api_macros::ExperimentalApi;
pub use codex_protocol::capabilities::CapabilityRootLocation;
pub use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
@@ -93,6 +94,12 @@ pub struct ThreadStartParams {
pub developer_instructions: Option<String>,
#[ts(optional = nullable)]
pub personality: Option<Personality>,
/// Set the initial multi-agent mode for this thread.
/// Omitted leaves the thread without a selected mode. Eligible multi-agent
/// v2 turns still default to `explicitRequestOnly`.
#[experimental("thread/start.multiAgentMode")]
#[ts(optional = nullable)]
pub multi_agent_mode: Option<MultiAgentMode>,
#[ts(optional = nullable)]
pub ephemeral: Option<bool>,
#[ts(optional = nullable)]
@@ -179,6 +186,10 @@ pub struct ThreadStartResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// Current selected multi-agent mode for this thread, if one was selected.
#[experimental("thread/start.multiAgentMode")]
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
}
impl ThreadStartResponse {
@@ -239,6 +250,10 @@ pub struct ThreadSettingsUpdateParams {
#[experimental("thread/settings/update.collaborationMode")]
#[ts(optional = nullable)]
pub collaboration_mode: Option<CollaborationMode>,
/// Select the multi-agent mode for subsequent turns.
#[experimental("thread/settings/update.multiAgentMode")]
#[ts(optional = nullable)]
pub multi_agent_mode: Option<MultiAgentMode>,
/// Override the personality for subsequent turns.
#[ts(optional = nullable)]
pub personality: Option<Personality>,
@@ -249,7 +264,7 @@ pub struct ThreadSettingsUpdateParams {
#[ts(export_to = "v2/")]
pub struct ThreadSettingsUpdateResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSettings {
@@ -264,6 +279,10 @@ pub struct ThreadSettings {
pub effort: Option<ReasoningEffort>,
pub summary: Option<ReasoningSummary>,
pub collaboration_mode: CollaborationMode,
/// Current selected multi-agent mode for this thread, if one was selected.
#[experimental("thread/settings.multiAgentMode")]
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
pub personality: Option<Personality>,
}
@@ -400,6 +419,10 @@ pub struct ThreadResumeResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// Current selected multi-agent mode for this thread, if one was selected.
#[experimental("thread/resume.multiAgentMode")]
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
/// `thread/turns/list` page returned when requested by `initialTurnsPage`.
#[experimental("thread/resume.initialTurnsPage")]
#[serde(default)]
@@ -555,6 +578,10 @@ pub struct ThreadForkResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// Current selected multi-agent mode for this thread, if one was selected.
#[experimental("thread/fork.multiAgentMode")]
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
}
impl ThreadForkResponse {
+5 -5
View File
@@ -137,17 +137,17 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/turns/list` — experimental; page through a stored threads turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/settings/update` — experimental; queue a partial update to a loaded threads next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/settings/update` — experimental; queue a partial update to a loaded threads next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/memoryMode/set` — experimental; set a threads persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`.
@@ -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". 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.
- `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 selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected 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"`.
@@ -639,6 +639,7 @@ pub(super) async fn handle_pending_thread_resume_request(
active_permission_profile,
workspace_roots,
reasoning_effort,
multi_agent_mode,
..
} = config_snapshot;
let instruction_sources = pending.instruction_sources;
@@ -661,6 +662,7 @@ pub(super) async fn handle_pending_thread_resume_request(
sandbox,
active_permission_profile,
reasoning_effort,
multi_agent_mode,
initial_turns_page,
};
outgoing.send_response(request_id, response).await;
@@ -2,6 +2,7 @@ use super::*;
use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
@@ -902,6 +903,7 @@ impl ThreadRequestProcessor {
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
multi_agent_mode,
ephemeral,
session_start_source,
thread_source,
@@ -955,6 +957,7 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation,
config,
typesafe_overrides,
multi_agent_mode,
dynamic_tools,
selected_capability_roots.unwrap_or_default(),
session_start_source,
@@ -1029,6 +1032,7 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
multi_agent_mode: Option<MultiAgentMode>,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
@@ -1152,6 +1156,7 @@ impl ThreadRequestProcessor {
thread_source,
dynamic_tools,
metrics_service_name: service_name,
multi_agent_mode,
parent_trace: request_trace,
environments,
thread_extension_init,
@@ -1257,6 +1262,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
};
let notif = thread_started_notification(thread);
listener_task_context
@@ -2787,6 +2793,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
initial_turns_page,
};
@@ -3508,6 +3515,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
};
let notif = thread_started_notification(thread);
@@ -1,5 +1,4 @@
use super::*;
#[cfg(test)]
use chrono::DateTime;
#[cfg(test)]
@@ -206,6 +205,7 @@ pub(crate) fn thread_settings_from_config_snapshot(
effort: config_snapshot.reasoning_effort.clone(),
summary: config_snapshot.reasoning_summary,
collaboration_mode: config_snapshot.collaboration_mode.clone(),
multi_agent_mode: config_snapshot.multi_agent_mode,
personality: config_snapshot.personality,
}
}
@@ -226,6 +226,7 @@ pub(crate) fn thread_settings_from_core_snapshot(
reasoning_summary,
personality,
collaboration_mode,
multi_agent_mode,
} = snapshot;
let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
ThreadSettings {
@@ -242,6 +243,7 @@ pub(crate) fn thread_settings_from_core_snapshot(
effort: reasoning_effort,
summary: reasoning_summary,
collaboration_mode,
multi_agent_mode,
personality,
}
}
@@ -729,7 +729,7 @@ impl TurnRequestProcessor {
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
multi_agent_mode: None,
multi_agent_mode: params.multi_agent_mode,
personality: params.personality,
},
)
+1
View File
@@ -241,6 +241,7 @@ mod tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
personality: None,
}
}
@@ -789,6 +789,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
base_instructions: None,
developer_instructions: None,
personality: None,
multi_agent_mode: None,
ephemeral: None,
session_start_source: None,
thread_source: None,
@@ -22,7 +22,10 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_features::Feature;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -94,6 +97,112 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() ->
Ok(())
}
#[tokio::test]
async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
(1..=2)
.map(|index| {
responses::sse(vec![
responses::ev_response_created(&format!("resp-{index}")),
responses::ev_assistant_message(&format!("msg-{index}"), "done"),
responses::ev_completed(&format!("resp-{index}")),
])
})
.collect(),
)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::from([
(Feature::MultiAgentV2, true),
(Feature::MultiAgentMode, true),
]),
/*auto_compact_limit*/ 200_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
start_text_turn(&mut mcp, thread.id.clone()).await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert_eq!(response_mock.requests().len(), 1);
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
.await?;
assert_eq!(
response_mock.requests().len(),
1,
"settings-only update should not start a model request"
);
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_id, thread.id);
assert_eq!(
updated.thread_settings.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
let first_developer_texts = requests[0].message_input_texts("developer");
let second_developer_texts = requests[1].message_input_texts("developer");
assert_eq!(
first_developer_texts
.iter()
.filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG))
.count(),
1
);
assert_eq!(
second_developer_texts
.iter()
.filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG))
.count(),
2
);
assert_eq!(
second_developer_texts
.iter()
.filter(|text| text.contains("Proactive multi-agent delegation is active."))
.count(),
1
);
assert_eq!(
second_developer_texts
.iter()
.filter(|text| text
.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents"))
.count(),
1
);
Ok(())
}
#[tokio::test]
async fn thread_settings_update_cwd_retargets_default_environment() -> Result<()> {
let server = responses::start_mock_server().await;
@@ -76,6 +76,7 @@ use codex_protocol::config_types::Settings;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
@@ -1827,6 +1828,140 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_multi_agent_mode_initializes_first_turn() -> 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()),
multi_agent_mode: Some(MultiAgentMode::Proactive),
..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,
multi_agent_mode,
..
} = to_response::<ThreadStartResponse>(thread_resp)?;
assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive));
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(),
}],
..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_OPEN_TAG)
&& text.contains("Proactive multi-agent delegation is active.")
}),
"expected proactive multi-agent mode instructions in developer input, got {developer_texts:?}"
);
Ok(())
}
#[tokio::test]
async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let cases = [
(
BTreeMap::from([(Feature::MultiAgentV2, true)]),
Some(MultiAgentMode::Proactive),
Some(MultiAgentMode::Proactive),
),
(
BTreeMap::new(),
Some(MultiAgentMode::Proactive),
Some(MultiAgentMode::Proactive),
),
(
BTreeMap::from([
(Feature::MultiAgentV2, true),
(Feature::MultiAgentMode, true),
]),
None,
None,
),
];
for (features, requested_multi_agent_mode, expected_multi_agent_mode) in cases {
let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never", &features)?;
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()),
multi_agent_mode: requested_multi_agent_mode,
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let response = to_response::<ThreadStartResponse>(thread_resp)?;
assert_eq!(response.multi_agent_mode, expected_multi_agent_mode);
}
Ok(())
}
#[tokio::test]
async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
+2
View File
@@ -19,6 +19,7 @@ use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ContentItem;
@@ -68,6 +69,7 @@ pub(crate) struct SpawnAgentOptions {
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) initial_multi_agent_mode: Option<MultiAgentMode>,
}
#[derive(Clone, Debug)]
@@ -142,6 +142,7 @@ async fn spawn_v2_subagent(
/*forked_from_thread_id*/ None,
Some(ThreadSource::Subagent),
/*metrics_service_name*/ None,
/*initial_multi_agent_mode*/ None,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
/*environments*/ None,
+6
View File
@@ -1,11 +1,13 @@
use super::residency::is_v2_resident_session_source;
use super::*;
use codex_protocol::config_types::MultiAgentMode;
const AGENT_NAMES: &str = include_str!("../agent_names.txt");
struct SpawnAgentThreadInheritance {
environments: Option<TurnEnvironmentSnapshot>,
exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
inherited_multi_agent_mode: Option<MultiAgentMode>,
}
fn default_agent_nickname_list() -> Vec<&'static str> {
@@ -237,6 +239,7 @@ impl AgentControl {
exec_policy: self
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
.await,
inherited_multi_agent_mode: options.initial_multi_agent_mode,
};
let (session_source, mut agent_metadata) = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
@@ -283,6 +286,7 @@ impl AgentControl {
/*forked_from_thread_id*/ None,
/*thread_source*/ Some(ThreadSource::Subagent),
/*metrics_service_name*/ None,
inheritance.inherited_multi_agent_mode,
inheritance.environments,
inheritance.exec_policy,
options.environments.clone(),
@@ -388,6 +392,7 @@ impl AgentControl {
let SpawnAgentThreadInheritance {
environments: inherited_environments,
exec_policy: inherited_exec_policy,
inherited_multi_agent_mode,
} = inheritance;
if options.fork_parent_spawn_call_id.is_none() {
return Err(CodexErr::Fatal(
@@ -513,6 +518,7 @@ impl AgentControl {
/*thread_source*/ Some(ThreadSource::Subagent),
/*parent_thread_id*/ Some(parent_thread_id),
/*forked_from_thread_id*/ Some(parent_thread_id),
inherited_multi_agent_mode,
inherited_environments,
inherited_exec_policy,
options.environments.clone(),
+106 -2
View File
@@ -14,6 +14,7 @@ use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
@@ -816,6 +817,45 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_history() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
.await
.expect("spawn child without parent history")
.thread_id;
let child_snapshot = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered")
.config_snapshot()
.await;
assert_eq!(
child_snapshot.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
}
#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
let harness = AgentControlHarness::new().await;
@@ -838,6 +878,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
})
.await
.expect("update parent multi-agent mode");
parent_thread
.inject_user_message_without_turn("parent seed context".to_string())
.await;
@@ -923,6 +972,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
@@ -935,6 +985,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert_eq!(
child_thread.config_snapshot().await.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
assert_ne!(child_thread_id, parent_thread_id);
let history = child_thread.codex.session.clone_history().await;
let expected_history = [
@@ -1242,6 +1296,15 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
})
.await
.expect("update parent multi-agent mode");
parent_thread
.inject_user_message_without_turn("old parent context".to_string())
@@ -1326,6 +1389,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
@@ -1338,6 +1402,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert_eq!(
child_thread.config_snapshot().await.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let history = child_thread.codex.session.clone_history().await;
assert!(
@@ -2185,7 +2253,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() {
let (home, mut config) = test_config().await;
config
.features
@@ -2207,7 +2275,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
manager,
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let agent_path = AgentPath::from_string("/root/explorer".to_string())
.expect("test agent path should be valid");
@@ -2232,6 +2300,38 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let mut child_turn_context = child_thread
.codex
.session
.new_default_turn()
.await
.to_turn_context_item();
child_turn_context.multi_agent_mode = Some(MultiAgentMode::Proactive);
child_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::TurnContext(child_turn_context)])
.await;
child_thread
.codex
.session
.ensure_rollout_materialized()
.await;
child_thread
.codex
.session
.flush_rollout()
.await
.expect("flush child effective multi-agent mode");
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly),
..Default::default()
})
.await
.expect("change parent multi-agent mode before child resume");
let mut status_rx = harness
.control
.subscribe_status(child_thread_id)
@@ -2320,6 +2420,10 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
assert_eq!(resumed_agent_path, Some(agent_path));
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
assert_eq!(
resumed_snapshot.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let _ = harness
.control
+1
View File
@@ -122,6 +122,7 @@ pub(crate) async fn run_codex_thread_interactive(
attestation_provider: parent_session.services.attestation_provider.clone(),
external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)),
inherited_multi_agent_version: Some(MultiAgentVersion::Disabled),
initial_multi_agent_mode: None,
}))
.or_cancel(&cancel_token)
.await??;
+1 -1
View File
@@ -106,7 +106,7 @@ fn build_multi_agent_mode_update_item(
&next.config.multi_agent_v2,
&next.session_source,
next.multi_agent_mode,
next.features.enabled(Feature::MultiAgentMode),
next.config.features.enabled(Feature::MultiAgentMode),
);
let previous = previous?;
if previous.multi_agent_mode == effective_multi_agent_mode {
+1
View File
@@ -178,6 +178,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
reasoning_summary: snapshot.reasoning_summary,
personality: snapshot.personality,
collaboration_mode: snapshot.collaboration_mode,
multi_agent_mode: snapshot.multi_agent_mode,
},
})
}
+7 -2
View File
@@ -445,6 +445,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
pub(crate) external_time_provider: Option<Arc<dyn TimeProvider>>,
pub(crate) inherited_multi_agent_version: Option<MultiAgentVersion>,
pub(crate) initial_multi_agent_mode: Option<MultiAgentMode>,
}
pub(crate) fn resolve_multi_agent_version(
@@ -529,6 +530,7 @@ impl Codex {
attestation_provider,
external_time_provider,
inherited_multi_agent_version,
initial_multi_agent_mode,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -583,7 +585,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();
let multi_agent_mode = initial_multi_agent_mode;
config
.validate_multi_agent_v2_config()
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
@@ -3249,7 +3251,10 @@ impl Session {
&turn_context.config.multi_agent_v2,
&session_source,
turn_context.multi_agent_mode,
turn_context.features.enabled(Feature::MultiAgentMode),
turn_context
.config
.features
.enabled(Feature::MultiAgentMode),
) {
items.push(ContextualUserFragment::into(
MultiAgentModeInstructions::new(multi_agent_mode),
@@ -738,6 +738,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
attestation_provider: None,
external_time_provider: None,
inherited_multi_agent_version: None,
initial_multi_agent_mode: None,
})
.await
.expect("spawn guardian subagent");
+1 -1
View File
@@ -388,7 +388,7 @@ impl TurnContext {
&self.config.multi_agent_v2,
&self.session_source,
self.multi_agent_mode,
self.features.enabled(Feature::MultiAgentMode),
self.config.features.enabled(Feature::MultiAgentMode),
),
realtime_active: Some(self.realtime_active),
effort: self.reasoning_effort.clone(),
+31 -3
View File
@@ -36,6 +36,7 @@ use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::openai_models::ModelPreset;
@@ -184,6 +185,7 @@ pub struct StartThreadOptions {
pub thread_source: Option<ThreadSource>,
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
pub metrics_service_name: Option<String>,
pub multi_agent_mode: Option<MultiAgentMode>,
pub parent_trace: Option<W3cTraceContext>,
pub environments: Vec<TurnEnvironmentSelection>,
pub thread_extension_init: ExtensionDataInit,
@@ -607,6 +609,7 @@ impl ThreadManager {
thread_source: None,
dynamic_tools,
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments,
thread_extension_init: ExtensionDataInit::default(),
@@ -646,6 +649,7 @@ impl ThreadManager {
thread_source,
options.dynamic_tools,
options.metrics_service_name,
options.multi_agent_mode,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
options.parent_trace,
@@ -730,6 +734,7 @@ impl ThreadManager {
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode();
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
@@ -741,6 +746,7 @@ impl ThreadManager {
thread_source,
Vec::new(),
/*metrics_service_name*/ None,
initial_multi_agent_mode,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
parent_trace,
@@ -773,6 +779,7 @@ impl ThreadManager {
/*thread_source*/ None,
Vec::new(),
/*metrics_service_name*/ None,
/*initial_multi_agent_mode*/ None,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
@@ -799,6 +806,7 @@ impl ThreadManager {
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode();
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
@@ -810,6 +818,7 @@ impl ThreadManager {
thread_source,
Vec::new(),
/*metrics_service_name*/ None,
initial_multi_agent_mode,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
/*parent_trace*/ None,
@@ -960,18 +969,25 @@ impl ThreadManager {
) -> CodexResult<NewThread> {
// `forked_from_id()` describes this history's existing lineage. When
// forking a resumed thread, the child copies the resumed thread itself.
let forked_from_thread_id = match &history {
let source_thread_id = match &history {
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
InitialHistory::Forked(_) => history.forked_from_id(),
InitialHistory::New | InitialHistory::Cleared => None,
};
let initial_multi_agent_mode = match source_thread_id {
Some(thread_id) => match self.get_thread(thread_id).await {
Ok(thread) => thread.config_snapshot().await.multi_agent_mode,
Err(_) => history.get_latest_effective_multi_agent_mode(),
},
None => history.get_latest_effective_multi_agent_mode(),
};
let multi_agent_version = self
.state
.effective_multi_agent_version_for_spawn(
&history,
/*session_source*/ None,
/*parent_thread_id*/ None,
forked_from_thread_id,
source_thread_id,
&config,
)
.await;
@@ -989,10 +1005,11 @@ impl ThreadManager {
Arc::clone(&self.state.auth_manager),
agent_control,
/*parent_thread_id*/ None,
forked_from_thread_id,
source_thread_id,
thread_source,
Vec::new(),
/*metrics_service_name*/ None,
initial_multi_agent_mode,
parent_trace,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
@@ -1217,6 +1234,7 @@ impl ThreadManagerState {
/*forked_from_thread_id*/ None,
/*thread_source*/ None,
/*metrics_service_name*/ None,
/*initial_multi_agent_mode*/ None,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
/*environments*/ None,
@@ -1234,6 +1252,7 @@ impl ThreadManagerState {
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
metrics_service_name: Option<String>,
initial_multi_agent_mode: Option<MultiAgentMode>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
@@ -1252,6 +1271,7 @@ impl ThreadManagerState {
thread_source,
Vec::new(),
metrics_service_name,
initial_multi_agent_mode,
inherited_environments,
inherited_exec_policy,
/*parent_trace*/ None,
@@ -1279,6 +1299,7 @@ impl ThreadManagerState {
let environments =
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd);
let thread_source = initial_history.get_resumed_thread_source();
let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode();
Box::pin(self.spawn_thread_with_source(
config,
initial_history,
@@ -1290,6 +1311,7 @@ impl ThreadManagerState {
thread_source,
Vec::new(),
/*metrics_service_name*/ None,
initial_multi_agent_mode,
inherited_environments,
inherited_exec_policy,
/*parent_trace*/ None,
@@ -1311,6 +1333,7 @@ impl ThreadManagerState {
thread_source: Option<ThreadSource>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
initial_multi_agent_mode: Option<MultiAgentMode>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
@@ -1329,6 +1352,7 @@ impl ThreadManagerState {
thread_source,
Vec::new(),
/*metrics_service_name*/ None,
initial_multi_agent_mode,
inherited_environments,
inherited_exec_policy,
/*parent_trace*/ None,
@@ -1353,6 +1377,7 @@ impl ThreadManagerState {
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
metrics_service_name: Option<String>,
initial_multi_agent_mode: Option<MultiAgentMode>,
parent_trace: Option<W3cTraceContext>,
environments: Vec<TurnEnvironmentSelection>,
thread_extension_init: ExtensionDataInit,
@@ -1370,6 +1395,7 @@ impl ThreadManagerState {
thread_source,
dynamic_tools,
metrics_service_name,
initial_multi_agent_mode,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
parent_trace,
@@ -1394,6 +1420,7 @@ impl ThreadManagerState {
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
metrics_service_name: Option<String>,
initial_multi_agent_mode: Option<MultiAgentMode>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
parent_trace: Option<W3cTraceContext>,
@@ -1473,6 +1500,7 @@ impl ThreadManagerState {
attestation_provider: self.attestation_provider.clone(),
external_time_provider: self.external_time_provider.clone(),
inherited_multi_agent_version: multi_agent_version,
initial_multi_agent_mode,
}))
.await?;
let new_thread = self
@@ -322,6 +322,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
@@ -462,6 +463,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: selected_root_init("selected-a", "env-a"),
@@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: selected_root_init("selected-b", "env-b"),
@@ -569,6 +572,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: environments.clone(),
thread_extension_init: Default::default(),
@@ -852,6 +856,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
thread_source: Some(ThreadSource::User),
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
@@ -132,6 +132,7 @@ async fn handle_spawn_agent(
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
parent_thread_id: Some(session.thread_id),
environments: Some(turn.environments.to_selections()),
initial_multi_agent_mode: None,
},
))
.await
@@ -24,6 +24,7 @@ use codex_model_provider_info::built_in_model_providers;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::models::BaseInstructions;
@@ -1147,6 +1148,11 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentMode)
.expect("test config should allow feature update");
turn.multi_agent_mode = Some(MultiAgentMode::Proactive);
set_turn_config(&mut turn, config);
let session = Arc::new(session);
@@ -1185,6 +1191,10 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
child_snapshot.session_source.get_agent_path().as_deref(),
Some("/root/test_process")
);
assert_eq!(
child_snapshot.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
assert!(manager.captured_ops().iter().any(|(id, op)| {
*id == child_thread_id
&& matches!(
@@ -50,6 +50,15 @@ async fn handle_spawn_agent(
let arguments = function_arguments(payload)?;
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
let fork_mode = args.fork_mode()?;
let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode(
turn.multi_agent_version,
&turn.config.multi_agent_v2,
&turn.session_source,
turn.multi_agent_mode,
turn.config
.features
.enabled(codex_features::Feature::MultiAgentMode),
);
let role_name = args
.agent_type
.as_deref()
@@ -134,6 +143,7 @@ async fn handle_spawn_agent(
fork_mode,
parent_thread_id: Some(session.thread_id),
environments: Some(turn.environments.to_selections()),
initial_multi_agent_mode: multi_agent_mode,
},
),
)
+1
View File
@@ -641,6 +641,7 @@ impl TestCodexBuilder {
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments,
thread_extension_init: Default::default(),
+2
View File
@@ -441,6 +441,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
@@ -648,6 +649,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: vec![
TurnEnvironmentSelection {
@@ -751,6 +751,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
+1
View File
@@ -790,5 +790,6 @@ fn sample_thread_start_response() -> ThreadStartResponse {
},
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
}
}
+1
View File
@@ -311,6 +311,7 @@ impl MemoryStartupContext {
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
metrics_service_name: None,
multi_agent_mode: None,
parent_trace: None,
environments,
thread_extension_init: Default::default(),
+45 -17
View File
@@ -1992,6 +1992,8 @@ pub struct ThreadSettingsSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
@@ -2554,12 +2556,24 @@ 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_latest_effective_multi_agent_mode(&self) -> Option<MultiAgentMode> {
let items = match self {
InitialHistory::New | InitialHistory::Cleared => return None,
InitialHistory::Resumed(resumed) => &resumed.history,
InitialHistory::Forked(items) => items,
};
items
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => Some(turn_context),
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_) => None,
})
.and_then(|turn_context| turn_context.multi_agent_mode)
}
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
@@ -2874,17 +2888,6 @@ 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")]
@@ -5399,6 +5402,31 @@ mod tests {
Ok(())
}
#[test]
fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> {
let turn_context_item = |multi_agent_mode| -> Result<RolloutItem> {
let mut value = json!({
"cwd": test_path_buf("/tmp"),
"approval_policy": "never",
"sandbox_policy": { "type": "danger-full-access" },
"model": "gpt-5",
"summary": "auto",
});
value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?;
Ok(RolloutItem::TurnContext(serde_json::from_value(value)?))
};
assert_eq!(
InitialHistory::Forked(vec![
turn_context_item(Some(MultiAgentMode::Proactive))?,
turn_context_item(/*multi_agent_mode*/ None)?,
])
.get_latest_effective_multi_agent_mode(),
None
);
Ok(())
}
#[test]
fn turn_context_item_serializes_network_when_present() -> Result<()> {
let item = TurnContextItem {
@@ -230,6 +230,7 @@ mod tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
personality: None,
}
}
+1
View File
@@ -6038,6 +6038,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode
effort: collaboration_mode.settings.reasoning_effort.clone(),
summary: None,
collaboration_mode: collaboration_mode.clone(),
multi_agent_mode: Default::default(),
personality: Some(Personality::Pragmatic),
},
};
+1
View File
@@ -2381,6 +2381,7 @@ mod tests {
.into(),
active_permission_profile: None,
reasoning_effort: None,
multi_agent_mode: Default::default(),
initial_turns_page: None,
};
@@ -30,6 +30,7 @@ fn thread_settings_for_test(
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
personality: Some(Personality::Pragmatic),
},
}