Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/core/src/agent/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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;
Expand Down Expand Up @@ -121,6 +122,12 @@ impl AgentControl {
self.session_id
}

pub(crate) async fn root_multi_agent_mode(&self) -> CodexResult<Option<MultiAgentMode>> {
let state = self.upgrade()?;
let root_thread = state.get_thread(ThreadId::from(self.session_id)).await?;
Ok(root_thread.config_snapshot().await.multi_agent_mode)
}

/// Send rich user input items to an existing agent thread.
pub(crate) async fn send_input(
&self,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/agent/control/residency_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions codex-rs/core/src/agent/control/spawn.rs
Original file line number Diff line number Diff line change
@@ -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> {
Expand Down Expand Up @@ -230,13 +232,27 @@ impl AgentControl {
agent_max_threads
};
let mut reservation = self.state.reserve_spawn_slot(reservation_max_threads)?;
let inherited_multi_agent_mode =
if let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
})) = session_source.as_ref()
{
match state.get_thread(*parent_thread_id).await {
Ok(parent_thread) => parent_thread.config_snapshot().await.multi_agent_mode,
Err(_) => None,
}
} else {
None
};
let inheritance = SpawnAgentThreadInheritance {
environments: self
.inherited_environments_for_source(&state, session_source.as_ref())
.await,
exec_policy: self
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
.await,
inherited_multi_agent_mode,
};
let (session_source, mut agent_metadata) = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
Expand Down Expand Up @@ -283,6 +299,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(),
Expand Down Expand Up @@ -388,6 +405,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(
Expand Down Expand Up @@ -513,6 +531,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(),
Expand Down
157 changes: 145 additions & 12 deletions codex-rs/core/src/agent/control_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -816,6 +817,70 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
assert_eq!(captured, Some(expected));
}

#[tokio::test]
async fn spawn_thread_subagent_inherits_parent_selected_multi_agent_mode_without_history() {
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");
let control = parent_thread.codex.session.services.agent_control.clone();

let child_thread_id = control
.spawn_agent(
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,
})),
)
.await
.expect("spawn child without parent history");
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)
);

parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly),
..Default::default()
})
.await
.expect("update parent multi-agent mode after child spawn");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should remain registered");
let child_turn = child_thread.codex.session.new_default_turn().await;

assert_eq!(
child_turn.multi_agent_mode,
Some(MultiAgentMode::ExplicitRequestOnly)
);
}

#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
let harness = AgentControlHarness::new().await;
Expand All @@ -838,6 +903,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;
Expand Down Expand Up @@ -935,6 +1009,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 = [
Expand Down Expand Up @@ -1241,6 +1319,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())
Expand Down Expand Up @@ -1337,6 +1424,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!(
Expand Down Expand Up @@ -2184,7 +2275,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 resumed_thread_subagent_uses_root_multi_agent_mode_for_its_next_turn() {
let (home, mut config) = test_config().await;
config
.features
Expand All @@ -2206,12 +2297,12 @@ 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 control = parent_thread.codex.session.services.agent_control.clone();
let agent_path = AgentPath::from_string("/root/explorer".to_string())
.expect("test agent path should be valid");

let child_thread_id = harness
.control
let child_thread_id = control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Expand All @@ -2231,8 +2322,39 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let mut status_rx = harness
.control
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 = control
.subscribe_status(child_thread_id)
.await
.expect("status subscription should succeed");
Expand Down Expand Up @@ -2273,14 +2395,12 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.await
.expect("child thread metadata should be persisted to sqlite before shutdown");

let _ = harness
.control
let _ = control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");

let resumed_thread_id = harness
.control
let resumed_thread_id = control
.resume_agent_from_rollout(
harness.config.clone(),
child_thread_id,
Expand Down Expand Up @@ -2319,9 +2439,22 @@ 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 resumed_thread = harness
.manager
.get_thread(resumed_thread_id)
.await
.expect("resumed child thread should remain registered");
let resumed_turn = resumed_thread.codex.session.new_default_turn().await;
assert_eq!(
resumed_turn.multi_agent_mode,
Some(MultiAgentMode::ExplicitRequestOnly)
);

let _ = harness
.control
let _ = control
.shutdown_live_agent(resumed_thread_id)
.await
.expect("resumed child shutdown should submit");
Expand Down
3 changes: 1 addition & 2 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,7 @@ impl Codex {
.await;
let multi_agent_version =
resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version);
let multi_agent_mode =
initial_multi_agent_mode.or_else(|| 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()))?;
Expand Down
12 changes: 11 additions & 1 deletion codex-rs/core/src/session/turn_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,10 +746,20 @@ impl Session {
async fn new_turn_context_from_configuration(
&self,
sub_id: String,
session_configuration: SessionConfiguration,
mut session_configuration: SessionConfiguration,
final_output_json_schema: Option<Option<Value>>,
multi_agent_runtime: TurnMultiAgentRuntime,
) -> Arc<TurnContext> {
// Thread-spawn subagents follow the root's current selection. If the root is unavailable,
// retain the subagent's persisted selection so standalone resume still has a fallback.
if matches!(
session_configuration.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) && let Ok(root_multi_agent_mode) =
self.services.agent_control.root_multi_agent_mode().await
{
session_configuration.multi_agent_mode = root_multi_agent_mode;
}
let turn_environments = self.services.turn_environments.snapshot().await;
let primary_turn_environment = turn_environments.primary().cloned();
// TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so
Expand Down
Loading
Loading