From fd36838cf30f837a0ae66f540c49e0f85432905c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 12:31:45 +0200 Subject: [PATCH] Add MultiAgentV2 root and subagent context hints (#19805) ## Why MultiAgentV2 sessions need startup guidance that matches the role of the thread that is actually being created. Root agents and subagents have different responsibilities, and forked subagents can inherit parent rollout history. If the parent hint is carried into the child context, the child can see stale or conflicting developer guidance before its own session-specific context is added. ## What changed - Added `features.multi_agent_v2.root_agent_usage_hint_text` and `features.multi_agent_v2.subagent_usage_hint_text` config fields, including schema/config parsing support. - Injected the matching root or subagent hint into the initial context as its own developer message when `multi_agent_v2` is enabled. - Filtered configured MultiAgentV2 usage-hint developer messages out of forked parent history so a child thread receives fresh guidance for its own session source/config. - Added targeted coverage for config parsing, initial-context rendering, feature-config deserialization, and forked-history filtering. ## Context examples With this config: ```toml [features.multi_agent_v2] enabled = true root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." ``` A root thread initial context renders the root hint as a standalone developer message: ```text [developer] [developer] Root guidance. ``` A subagent thread initial context renders the subagent hint instead: ```text [developer] [developer] Subagent guidance. ``` When a subagent forks parent history, any parent developer message whose text exactly matches the configured MultiAgentV2 root or subagent hint is omitted from the forked history before the child receives its fresh subagent hint. --- codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/agent/control.rs | 35 ++++++- codex-rs/core/src/agent/control_tests.rs | 38 ++++++- codex-rs/core/src/config/config_tests.rs | 22 ++++ codex-rs/core/src/config/mod.rs | 16 +++ codex-rs/core/src/session/mod.rs | 30 +++++- codex-rs/core/src/session/multi_agents.rs | 18 ++++ codex-rs/core/src/session/tests.rs | 118 ++++++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 4 + codex-rs/features/src/tests.rs | 6 ++ 10 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/session/multi_agents.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bdccafcb53..c4a72c90b0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1315,6 +1315,12 @@ "minimum": 1.0, "type": "integer" }, + "root_agent_usage_hint_text": { + "type": "string" + }, + "subagent_usage_hint_text": { + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 433e3b8c91..187fc29e9d 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -19,6 +19,7 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; @@ -395,7 +396,39 @@ impl AgentControl { forked_rollout_items = truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); } - forked_rollout_items.retain(keep_forked_rollout_item); + // MultiAgentV2 root/subagent usage hints are injected as standalone developer + // messages at thread start. When forking history, drop hints from the parent + // so the child gets a fresh hint that matches its own session source/config. + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + parent_thread + .codex + .configured_multi_agent_v2_usage_hint_texts() + .await + } else if config.features.enabled(Feature::MultiAgentV2) { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + forked_rollout_items.retain(|item| { + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item + && role == "developer" + && let [ContentItem::InputText { text }] = content.as_slice() + && multi_agent_v2_usage_hint_texts_to_filter + .iter() + .any(|usage_hint_text| usage_hint_text == text) + { + return false; + } + + keep_forked_rollout_item(item) + }); state .fork_thread_with_source( diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index daa86718fa..8ce7fa2ea2 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -595,7 +595,25 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + parent_config.multi_agent_v2.subagent_usage_hint_text = + Some("Parent subagent guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Child root guidance.".to_string()); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; @@ -614,6 +632,22 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .record_conversation_items( turn_context.as_ref(), &[ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent subagent guidance.".to_string(), + }], + phase: None, + }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), @@ -643,7 +677,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let child_thread_id = harness .control .spawn_agent_with_metadata( - harness.config.clone(), + child_config, text_input("child task"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21a86dcadd..a2b5312103 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7444,6 +7444,8 @@ enabled = true max_concurrent_threads_per_session = 5 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." +root_agent_usage_hint_text = "Root guidance." +subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true "#, )?; @@ -7462,6 +7464,14 @@ hide_spawn_agent_metadata = true config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") ); + assert_eq!( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + Some("Root guidance.") + ); + assert_eq!( + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + Some("Subagent guidance.") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); Ok(()) @@ -7478,12 +7488,16 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { max_concurrent_threads_per_session = 4 usage_hint_enabled = true usage_hint_text = "base hint" +root_agent_usage_hint_text = "base root hint" +subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 usage_hint_enabled = false usage_hint_text = "profile hint" +root_agent_usage_hint_text = "profile root hint" +subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false "#, )?; @@ -7500,6 +7514,14 @@ hide_spawn_agent_metadata = false config.multi_agent_v2.usage_hint_text.as_deref(), Some("profile hint") ); + assert_eq!( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + Some("profile root hint") + ); + assert_eq!( + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + Some("profile subagent hint") + ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); Ok(()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f4a73dd25f..1150aed7e7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -731,6 +731,8 @@ pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, pub usage_hint_enabled: bool, pub usage_hint_text: Option, + pub root_agent_usage_hint_text: Option, + pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, } @@ -741,6 +743,8 @@ impl Default for MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, usage_hint_enabled: true, usage_hint_text: None, + root_agent_usage_hint_text: None, + subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, } } @@ -1619,6 +1623,16 @@ fn resolve_multi_agent_v2_config( .or_else(|| base.and_then(|config| config.usage_hint_text.as_ref())) .cloned() .or(default.usage_hint_text); + let root_agent_usage_hint_text = profile + .and_then(|config| config.root_agent_usage_hint_text.as_ref()) + .or_else(|| base.and_then(|config| config.root_agent_usage_hint_text.as_ref())) + .cloned() + .or(default.root_agent_usage_hint_text); + let subagent_usage_hint_text = profile + .and_then(|config| config.subagent_usage_hint_text.as_ref()) + .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) + .cloned() + .or(default.subagent_usage_hint_text); let hide_spawn_agent_metadata = profile .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) @@ -1628,6 +1642,8 @@ fn resolve_multi_agent_v2_config( max_concurrent_threads_per_session, usage_hint_enabled, usage_hint_text, + root_agent_usage_hint_text, + subagent_usage_hint_text, hide_spawn_agent_metadata, } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c01363f55a..cb88f44461 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -184,6 +184,7 @@ use codex_protocol::exec_output::StreamOutput; mod handlers; mod mcp; +mod multi_agents; mod review; mod rollout_reconstruction; #[allow(clippy::module_inception)] @@ -763,6 +764,22 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec { + let state = self.session.state.lock().await; + let config = &state.session_configuration.original_config_do_not_use; + if !config.features.enabled(Feature::MultiAgentV2) { + return Vec::new(); + } + + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } + pub(crate) fn state_db(&self) -> Option { self.session.state_db() } @@ -2654,12 +2671,23 @@ impl Session { ); } - let mut items = Vec::with_capacity(3); + let multi_agent_v2_usage_hint_text = + multi_agents::usage_hint_text(turn_context, &session_source); + + let mut items = Vec::with_capacity(4); if let Some(developer_message) = crate::context_manager::updates::build_developer_update_item(developer_sections) { items.push(developer_message); } + if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text + && let Some(usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + usage_hint_text.to_string(), + ]) + { + items.push(usage_hint_message); + } 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 new file mode 100644 index 0000000000..c94f89602c --- /dev/null +++ b/codex-rs/core/src/session/multi_agents.rs @@ -0,0 +1,18 @@ +use crate::session::turn_context::TurnContext; +use codex_features::Feature; +use codex_protocol::protocol::SessionSource; + +pub(super) fn usage_hint_text<'a>( + turn_context: &'a TurnContext, + session_source: &SessionSource, +) -> Option<&'a str> { + if !turn_context.features.enabled(Feature::MultiAgentV2) { + return None; + } + + let multi_agent_v2 = &turn_context.config.multi_agent_v2; + match session_source { + SessionSource::SubAgent(_) => multi_agent_v2.subagent_usage_hint_text.as_deref(), + _ => multi_agent_v2.root_agent_usage_hint_text.as_deref(), + } +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9079f5990d..b6153e724d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -372,6 +372,27 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { .collect() } +fn developer_message_texts(items: &[ResponseItem]) -> Vec> { + items + .iter() + .filter_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + Some(content.as_slice()) + } + _ => None, + }) + .map(|content| { + content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }) + .collect() + }) + .collect() +} + fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> { items .iter() @@ -5313,6 +5334,103 @@ async fn build_initial_context_uses_previous_realtime_state() { ); } +async fn make_multi_agent_v2_usage_hint_test_session( + enable_multi_agent_v2: bool, +) -> (Arc, Arc) { + let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + if enable_multi_agent_v2 { + let _ = config.features.enable(Feature::MultiAgentV2); + } + config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); + config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); + }, + ) + .await; + (session, turn_context) +} + +#[tokio::test] +async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() { + let (session, turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + developer_messages + .iter() + .any(|message| message.as_slice() == ["Root guidance."]), + "expected standalone root usage hint developer message, got {developer_messages:?}" + ); + assert!( + !developer_messages + .iter() + .any(|message| message.as_slice() == ["Subagent guidance."]), + "did not expect subagent usage hint for root thread, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_developer_message() { + let (session, mut turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: Some(AgentPath::try_from("/root/worker").expect("agent path should parse")), + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + session + .state + .lock() + .await + .session_configuration + .session_source = session_source.clone(); + Arc::get_mut(&mut turn_context) + .expect("turn context should not be shared") + .session_source = session_source; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + developer_messages + .iter() + .any(|message| message.as_slice() == ["Subagent guidance."]), + "expected standalone subagent usage hint developer message, got {developer_messages:?}" + ); + assert!( + !developer_messages + .iter() + .any(|message| message.as_slice() == ["Root guidance."]), + "did not expect root usage hint for subagent thread, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_disabled() { + let (session, turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + !developer_messages.iter().any(|message| { + matches!( + message.as_slice(), + ["Root guidance."] | ["Subagent guidance."] + ) + }), + "did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index bead1ce037..3906bc2e83 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -16,6 +16,10 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub root_agent_usage_hint_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subagent_usage_hint_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, } diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 6b1d638ffe..4e1f9a0951 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -415,6 +415,8 @@ enabled = true max_concurrent_threads_per_session = 4 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." +root_agent_usage_hint_text = "Root guidance." +subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true "#, ) @@ -431,6 +433,8 @@ hide_spawn_agent_metadata = true max_concurrent_threads_per_session: Some(4), usage_hint_enabled: Some(false), usage_hint_text: Some("Custom delegation guidance.".to_string()), + root_agent_usage_hint_text: Some("Root guidance.".to_string()), + subagent_usage_hint_text: Some("Subagent guidance.".to_string()), hide_spawn_agent_metadata: Some(true), })) ); @@ -463,6 +467,8 @@ usage_hint_enabled = false max_concurrent_threads_per_session: None, usage_hint_enabled: Some(false), usage_hint_text: None, + root_agent_usage_hint_text: None, + subagent_usage_hint_text: None, hide_spawn_agent_metadata: None, })) );