mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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] <existing developer context, when present> [developer] Root guidance. ``` A subagent thread initial context renders the subagent hint instead: ```text [developer] <existing developer context, when present> [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.
This commit is contained in:
committed by
GitHub
Unverified
parent
803705f795
commit
fd36838cf3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<String> =
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -731,6 +731,8 @@ pub struct MultiAgentV2Config {
|
||||
pub max_concurrent_threads_per_session: usize,
|
||||
pub usage_hint_enabled: bool,
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<state_db::StateDbHandle> {
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -372,6 +372,27 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn developer_message_texts(items: &[ResponseItem]) -> Vec<Vec<&str>> {
|
||||
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<Session>, Arc<TurnContext>) {
|
||||
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;
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct MultiAgentV2ConfigToml {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hide_spawn_agent_metadata: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user