From 49025589b0216b876b1a6a20977536c7d55cdb8b Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 28 Jul 2026 03:28:50 +0000 Subject: [PATCH] Add configurable developer instructions for v2 subagents (#35708) ## What changed - Add `features.multi_agent_v2.subagent_developer_instructions` to override inherited developer instructions for subagents that do not define role-specific instructions. - Preserve role-specific instructions as the higher-precedence setting, and carry the effective instructions through full and bounded forks, compacted histories, and cold resume without duplication. - Treat an unset override as inheritance and a blank override as clearing inherited instructions. ## Testing - Cover configuration parsing and materialization, instruction precedence, fork modes, compaction, role application, and resumed subagents. GitOrigin-RevId: 32321e61abc59b14a1be5f31e6bd6570bb1cd89d --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + .../multi_agent_v2_developer_instructions.rs | 454 +++++++++++++++++- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/agent/control/spawn.rs | 179 +++++-- codex-rs/core/src/agent/control_tests.rs | 349 +++++++++++++- codex-rs/core/src/agent/role.rs | 62 ++- codex-rs/core/src/config/config_tests.rs | 10 + codex-rs/core/src/config/mod.rs | 6 + codex-rs/core/src/session/config_lock.rs | 5 +- .../src/tools/handlers/multi_agents_common.rs | 22 +- .../core/tests/suite/multi_agent_resume.rs | 5 + codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 4 + 14 files changed, 1035 insertions(+), 70 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7605260abc..c85717fe05 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2030,6 +2030,7 @@ dependencies = [ "shlex", "tar", "tempfile", + "test-case", "thiserror 2.0.18", "time", "tokio", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index cf02f023cf..f9cc95b843 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -138,6 +138,7 @@ serial_test = { workspace = true } sha2 = { workspace = true } shlex = { workspace = true } tar = { workspace = true } +test-case = "3.3.1" tokio-tungstenite = { workspace = true } tracing-opentelemetry = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs index 958176062a..6ccea58c01 100644 --- a/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs +++ b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs @@ -11,21 +11,436 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use core_test_support::responses; +use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; use tempfile::TempDir; +use test_case::test_case; use tokio::time::timeout; #[cfg(windows)] const READ_TIMEOUT: Duration = Duration::from_secs(25); #[cfg(not(windows))] const READ_TIMEOUT: Duration = Duration::from_secs(10); +const NAMESPACE: &str = "collaboration"; +const PARENT_INSTRUCTIONS: &str = "parent-only developer instructions"; +const CHILD_INSTRUCTIONS: &str = "child-only developer instructions"; +const ROLE_INSTRUCTIONS: &str = "configured role developer instructions"; -/// A roleless worker keeps inherited developer instructions across cold root resume and lazy reload. +/// V2 fork modes, roles, and unset/blank overrides expose their agreed instruction precedence. +#[test_case("no history"; "no history")] +#[test_case("full history"; "full history")] +#[test_case("bounded history"; "bounded history")] +#[test_case("configured role without instructions"; "configured role without instructions")] +#[test_case("unset override"; "unset override")] +#[test_case("blank override"; "blank override")] +#[test_case("parent has no instructions"; "parent has no instructions")] +#[test_case("explicit configured role"; "explicit configured role")] +#[test_case("implicit configured default"; "implicit configured default")] +#[test_case("full fork skips default role"; "full fork skips default role")] #[tokio::test] -async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_worker() -> Result<()> -{ - const PARENT_INSTRUCTIONS: &str = "parent-only developer instructions"; +async fn spawned_subagents_apply_configured_developer_instruction_precedence( + case: &str, +) -> Result<()> { + let fork_turns = match case { + "bounded history" => Some("1"), + "no history" + | "configured role without instructions" + | "explicit configured role" + | "implicit configured default" => Some("none"), + _ => None, + }; + let agent_type = match case { + "configured role without instructions" | "explicit configured role" => Some("custom"), + _ => None, + }; + let configured_override = match case { + "unset override" => None, + "blank override" => Some(" "), + "full history" => Some(" child-only developer instructions "), + _ => Some(CHILD_INSTRUCTIONS), + }; + let parent = if case == "parent has no instructions" { + None + } else { + Some(PARENT_INSTRUCTIONS) + }; + let configured_roles = matches!( + case, + "configured role without instructions" + | "explicit configured role" + | "implicit configured default" + | "full fork skips default role" + ); + let role_has_instructions = matches!( + case, + "explicit configured role" | "implicit configured default" | "full fork skips default role" + ); + let expected = match case { + "unset override" => Some(PARENT_INSTRUCTIONS), + "blank override" => None, + "explicit configured role" | "implicit configured default" => Some(ROLE_INSTRUCTIONS), + _ => Some(CHILD_INSTRUCTIONS), + }; + const PARENT_PROMPT: &str = "spawn the instruction override worker"; + const CHILD_PROMPT: &str = "perform the instruction override task"; + const SPAWN_CALL_ID: &str = "spawn-instruction-override-worker"; + + let server = responses::start_mock_server().await; + let mut spawn_args = json!({"message": CHILD_PROMPT, "task_name": "worker"}); + if let Some(fork_turns) = fork_turns { + spawn_args["fork_turns"] = json!(fork_turns); + } + if let Some(agent_type) = agent_type { + spawn_args["agent_type"] = json!(agent_type); + } + let parent_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(PARENT_PROMPT) + }, + responses::sse(vec![ + responses::ev_response_created("parent-spawn"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + NAMESPACE, + "spawn_agent", + &serde_json::to_string(&spawn_args)?, + ), + responses::ev_completed("parent-spawn"), + ]), + ) + .await; + let child_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(CHILD_PROMPT) && !body.contains(SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("child-work"), + responses::ev_assistant_message("child-message", "child complete"), + responses::ev_completed("child-work"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("parent-complete"), + responses::ev_assistant_message("parent-message", "parent complete"), + responses::ev_completed("parent-complete"), + ]), + ) + .await; + + let mut feature_config = "[features.multi_agent_v2]\nenabled = true".to_string(); + if let Some(configured_override) = configured_override { + feature_config.push_str(&format!( + "\nsubagent_developer_instructions = {configured_override:?}" + )); + } + if configured_roles { + feature_config.push_str( + "\n\n[agents.custom]\ndescription = \"configured role\"\nconfig_file = \"./config.toml\"\n\n[agents.default]\ndescription = \"configured default role\"\nconfig_file = \"./config.toml\"", + ); + } + let codex_home = TempDir::new()?; + let mut config = MockResponsesConfig::new(&server.uri()).with_model("gpt-5.4"); + if role_has_instructions { + config = + config.with_root_config(&format!("developer_instructions = {ROLE_INSTRUCTIONS:?}")); + } + config + .with_extra_config(&feature_config) + .write(codex_home.path())?; + write_models_cache(codex_home.path())?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let ThreadStartResponse { thread, .. } = app_server + .start_thread(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + developer_instructions: parent.map(str::to_string), + ..Default::default() + }) + .await?; + let _: TurnStartResponse = app_server + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: PARENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + let child_request = timeout(READ_TIMEOUT, async { + loop { + if let Some(request) = child_request + .requests() + .into_iter() + .find(|request| !request.inputs_of_type("agent_message").is_empty()) + { + break request; + } + tokio::task::yield_now().await; + } + }) + .await?; + + let parent_texts = parent_request + .single_request() + .message_input_texts("developer"); + if let Some(parent) = parent { + assert!( + parent_texts.iter().any(|text| text == parent), + "{case}: parent developer instructions unexpectedly changed: {parent_texts:?}" + ); + } + let child_texts = child_request.message_input_texts("developer"); + let instruction_texts = child_texts + .iter() + .map(String::as_str) + .filter(|text| { + matches!( + *text, + PARENT_INSTRUCTIONS | CHILD_INSTRUCTIONS | ROLE_INSTRUCTIONS + ) + }) + .collect::>(); + let expected_instruction_texts = match expected { + Some(instructions) => vec![instructions], + None => Vec::new(), + }; + assert_eq!( + instruction_texts, expected_instruction_texts, + "{case}: child received unexpected developer instructions" + ); + assert!( + child_texts.iter().all(|text| !text.is_empty()), + "{case}: an empty developer fragment reached the model: {child_texts:?}" + ); + + Ok(()) +} + +/// A full-history worker fork replaces parent instructions inside persisted compacted history. +#[tokio::test] +async fn compacted_full_history_fork_replaces_parent_developer_instructions() -> Result<()> { + const COMPACT_SETUP_PROMPT: &str = "prepare the parent for compaction"; + const COMPACT_PROMPT: &str = "summarize the compacted parent"; + const COMPACTED_SUMMARY: &str = "preserved compacted parent summary"; + const SPAWN_PROMPT: &str = "spawn the compacted-history worker"; + const CHILD_PROMPT: &str = "inspect the compacted parent history"; + const SETUP_CALL_ID: &str = "trigger-parent-compaction"; + const SPAWN_CALL_ID: &str = "spawn-compacted-history-worker"; + + let server = responses::start_mock_server().await; + let compaction_requests = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("parent-before-compaction"), + responses::ev_function_call(SETUP_CALL_ID, "unsupported_tool", "{}"), + responses::ev_completed_with_tokens( + "parent-before-compaction", + /*total_tokens*/ 96, + ), + ]), + responses::sse(vec![ + responses::ev_response_created("parent-compaction"), + responses::ev_assistant_message("parent-summary", COMPACTED_SUMMARY), + responses::ev_completed_with_tokens("parent-compaction", /*total_tokens*/ 10), + ]), + responses::sse(vec![ + responses::ev_response_created("parent-after-compaction"), + responses::ev_assistant_message("parent-ready", "parent history compacted"), + responses::ev_completed_with_tokens( + "parent-after-compaction", + /*total_tokens*/ 10, + ), + ]), + ], + ) + .await; + let parent_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| String::from_utf8_lossy(&request.body).contains(SPAWN_PROMPT), + responses::sse(vec![ + responses::ev_response_created("parent-spawn-after-compaction"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + NAMESPACE, + "spawn_agent", + &serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "compacted_worker", + }))?, + ), + responses::ev_completed("parent-spawn-after-compaction"), + ]), + ) + .await; + let child_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(CHILD_PROMPT) && !body.contains(SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("compacted-child-work"), + responses::ev_assistant_message("compacted-child-message", "child complete"), + responses::ev_completed("compacted-child-work"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("compacted-parent-complete"), + responses::ev_assistant_message("compacted-parent-message", "parent complete"), + responses::ev_completed("compacted-parent-complete"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_model("gpt-5.4") + .with_root_config(&format!( + "developer_instructions = {PARENT_INSTRUCTIONS:?}\nmodel_context_window = 100\nmodel_auto_compact_token_limit = 90\ncompact_prompt = {COMPACT_PROMPT:?}" + )) + .with_extra_config(&format!( + "[features.multi_agent_v2]\nenabled = true\nsubagent_developer_instructions = {CHILD_INSTRUCTIONS:?}" + )) + .write(codex_home.path())?; + write_models_cache(codex_home.path())?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let ThreadStartResponse { thread, .. } = app_server + .start_thread(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + timeout( + READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: COMPACT_SETUP_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }), + ) + .await??; + + let compaction_requests = compaction_requests.requests(); + assert_eq!(compaction_requests.len(), 3); + assert!( + compaction_requests[1].body_contains_text(COMPACT_PROMPT), + "the setup turn should perform actual mid-turn compaction" + ); + assert!( + compaction_requests[2] + .message_input_texts("developer") + .iter() + .any(|text| text == PARENT_INSTRUCTIONS), + "mid-turn compaction should retain parent instructions in its replacement history" + ); + + let _: TurnStartResponse = app_server + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: SPAWN_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + let child_request = timeout(READ_TIMEOUT, async { + loop { + if let Some(request) = child_request + .requests() + .into_iter() + .find(|request| !request.inputs_of_type("agent_message").is_empty()) + { + break request; + } + tokio::task::yield_now().await; + } + }) + .await?; + + assert!( + parent_request + .single_request() + .message_input_texts("developer") + .iter() + .any(|text| text == PARENT_INSTRUCTIONS), + "the parent should retain its own developer instructions after compaction" + ); + assert!( + child_request.body_contains_text(COMPACTED_SUMMARY), + "the full-history child should inherit the compacted parent summary" + ); + let child_developer_texts = child_request.message_input_texts("developer"); + assert_eq!( + child_developer_texts + .iter() + .filter(|text| text.as_str() == CHILD_INSTRUCTIONS) + .count(), + 1, + "the child should receive its configured developer instructions exactly once" + ); + assert!( + child_developer_texts + .iter() + .all(|text| text != PARENT_INSTRUCTIONS), + "the child should not inherit parent instructions from compacted history" + ); + + Ok(()) +} + +/// Cold root resume preserves inherited instructions or reapplies the configured v2 override. +#[test_case( + None, + PARENT_INSTRUCTIONS, + CHILD_INSTRUCTIONS; + "inherits parent developer instructions without an override" +)] +#[test_case( + Some(CHILD_INSTRUCTIONS), + CHILD_INSTRUCTIONS, + PARENT_INSTRUCTIONS; + "reapplies configured subagent developer instructions" +)] +#[tokio::test] +async fn cold_resume_preserves_effective_developer_instructions_for_roleless_worker( + configured_subagent_developer_instructions: Option<&str>, + expected_developer_instructions: &str, + unexpected_developer_instructions: &str, +) -> Result<()> { const INITIAL_PROMPT: &str = "spawn a durable instruction worker"; const INITIAL_TASK: &str = "perform the initial durable instruction task"; const FOLLOWUP_PROMPT: &str = "continue the durable instruction worker"; @@ -33,7 +448,6 @@ async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_wor const SPAWN_CALL_ID: &str = "spawn-durable-instruction-worker"; const WAIT_CALL_ID: &str = "wait-for-durable-instruction-worker"; const FOLLOWUP_CALL_ID: &str = "followup-durable-instruction-worker"; - const NAMESPACE: &str = "collaboration"; let server = responses::start_mock_server().await; responses::mount_sse_once_match( @@ -94,11 +508,17 @@ async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_wor ) .await; + let feature_config = match configured_subagent_developer_instructions { + Some(instructions) => format!( + "[features.multi_agent_v2]\nenabled = true\nsubagent_developer_instructions = {instructions:?}" + ), + None => "[features.multi_agent_v2]\nenabled = true".to_string(), + }; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()) .with_model("gpt-5.4") .with_root_config(&format!("developer_instructions = {PARENT_INSTRUCTIONS:?}")) - .with_extra_config("[features.multi_agent_v2]\nenabled = true") + .with_extra_config(&feature_config) .write(codex_home.path())?; write_models_cache(codex_home.path())?; @@ -125,7 +545,6 @@ async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_wor }), ) .await??; - let initial_child_request = initial_child_request .requests() .into_iter() @@ -135,10 +554,15 @@ async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_wor assert!( developer_texts .iter() - .any(|text| text == PARENT_INSTRUCTIONS), - "worker did not inherit parent developer instructions: {developer_texts:?}" + .any(|text| text == expected_developer_instructions), + "initial worker lost its effective developer instructions: {developer_texts:?}" + ); + assert!( + developer_texts + .iter() + .all(|text| text != unexpected_developer_instructions), + "initial worker received the wrong developer instructions: {developer_texts:?}" ); - let shutdown = timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??; assert!( shutdown.success(), @@ -247,8 +671,14 @@ async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_wor assert!( developer_texts .iter() - .any(|text| text == PARENT_INSTRUCTIONS), - "resumed worker lost its inherited developer instructions: {developer_texts:?}" + .any(|text| text == expected_developer_instructions), + "resumed worker lost its effective developer instructions: {developer_texts:?}" + ); + assert!( + developer_texts + .iter() + .all(|text| text != unexpected_developer_instructions), + "resumed worker received the wrong developer instructions: {developer_texts:?}" ); Ok(()) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f3fb1b36c6..bcce6023cb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1851,6 +1851,10 @@ "root_agent_usage_hint_text": { "type": "string" }, + "subagent_developer_instructions": { + "description": "Overrides inherited developer instructions for subagents without role-specific instructions.", + "type": "string" + }, "subagent_usage_hint_text": { "type": "string" }, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index db4de9b09d..9e994fa793 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,6 +1,6 @@ use super::residency::is_v2_resident_session_source; use super::*; -use crate::agent::role::apply_role_to_config; +use crate::agent::role::apply_role_to_config_for_multi_agent_v2; use crate::config::PermissionProfileSnapshot; use codex_extension_api::ExtensionDataInit; @@ -301,7 +301,7 @@ impl AgentControl { ), }; - apply_role_to_config(&mut config, Some(&role_name)) + apply_role_to_config_for_multi_agent_v2(&mut config, Some(&role_name)) .await .map_err(CodexErr::InvalidRequest)?; config @@ -595,6 +595,32 @@ impl AgentControl { let parent_thread_id = *parent_thread_id; let parent_thread = state.get_thread(parent_thread_id).await?; + let (subagent_developer_instructions, parent_developer_instructions) = match ( + multi_agent_version, + config + .multi_agent_v2 + .subagent_developer_instructions + .as_ref(), + ) { + (MultiAgentVersion::V2, Some(_)) => { + let parent_developer_instructions = match parent_thread + .session + .new_default_turn() + .await + .developer_instructions + .clone() + { + Some(instructions) if !instructions.is_empty() => Some(instructions), + Some(_) | None => None, + }; + ( + Some(config.developer_instructions.clone().unwrap_or_default()), + parent_developer_instructions, + ) + } + (MultiAgentVersion::Disabled | MultiAgentVersion::V1, _) + | (MultiAgentVersion::V2, None) => (None, None), + }; let parent_history_mode = parent_thread.config_snapshot().await.history_mode; // `record_conversation_items` only queues persistence writes asynchronously. // Flush before snapshotting store history for a fork. @@ -644,43 +670,122 @@ impl AgentControl { } else { Vec::new() }; - let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); - forked_rollout_items.retain(|item| { - keep_forked_rollout_item(item, preserve_reference_context_item) - && !matches!( - item, - RolloutItem::ResponseItem(response_item) - if is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - ) - }); - if destination_history_mode == Some(ThreadHistoryMode::Paginated) { - forked_rollout_items.retain(|item| { - !matches!( - item, - RolloutItem::EventMsg( - EventMsg::ItemCompleted(_) - | EventMsg::TokenCount(_) - | EventMsg::ThreadGoalUpdated(_) - | EventMsg::ThreadSettingsApplied(_), - ) - ) - }); - } - for item in &mut forked_rollout_items { - if let RolloutItem::Compacted(compacted) = item - && let Some(replacement_history) = compacted.replacement_history.as_mut() - { - replacement_history.retain(|response_item| { - !is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - }); + let mut preserve_reference_context_item = + matches!(fork_mode, SpawnAgentForkMode::FullHistory); + if preserve_reference_context_item { + for item in forked_rollout_items.iter().rev() { + let RolloutItem::Compacted(compacted) = item else { + continue; + }; + // Legacy checkpoints force the child to rebuild context regardless of the + // live parent's reference baseline; an older superseded checkpoint does not. + if compacted.replacement_history.is_none() { + preserve_reference_context_item = false; + } + break; } } + let mut replaced_parent_developer_instructions = false; + // Scrub inherited hints and replace only the parent's developer-instruction fragment. + // Compaction stores response items separately, so sanitize both top-level messages and + // compacted replacement histories with the same policy. + let retain_forked_item = |response_item: &mut ResponseItem, replaced: &mut bool| { + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) { + return false; + } + + if let Some(parent_developer_instructions) = parent_developer_instructions.as_ref() + && let Some(subagent_developer_instructions) = + subagent_developer_instructions.as_ref() + && let ResponseItem::Message { role, content, .. } = response_item + && role == "developer" + { + content.retain_mut(|content_item| { + let ContentItem::InputText { text } = content_item else { + return true; + }; + // TODO(anp) track better message fragment provenance in rollouts. + if !text.contains(parent_developer_instructions) { + return true; + } + + *replaced = true; + let replacement = if preserve_reference_context_item { + subagent_developer_instructions.as_str() + } else { + "" + }; + *text = text.replace(parent_developer_instructions, replacement); + !text.is_empty() + }); + return !content.is_empty(); + } + + true + }; + forked_rollout_items.retain_mut(|item| { + if !keep_forked_rollout_item(item, preserve_reference_context_item) + || destination_history_mode == Some(ThreadHistoryMode::Paginated) + && matches!( + &*item, + RolloutItem::EventMsg( + EventMsg::ItemCompleted(_) + | EventMsg::TokenCount(_) + | EventMsg::ThreadGoalUpdated(_) + | EventMsg::ThreadSettingsApplied(_), + ) + ) + { + return false; + } + + match item { + RolloutItem::ResponseItem(response_item) => { + retain_forked_item(response_item, &mut replaced_parent_developer_instructions) + } + RolloutItem::Compacted(compacted) => { + if let Some(replacement_history) = compacted.replacement_history.as_mut() { + // Matches before this checkpoint cannot survive its replacement history. + replaced_parent_developer_instructions = false; + replacement_history.retain_mut(|response_item| { + retain_forked_item( + response_item, + &mut replaced_parent_developer_instructions, + ) + }); + } + true + } + RolloutItem::EventMsg(_) + | RolloutItem::SessionMeta(_) + | RolloutItem::TurnContext(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::WorldState(_) => true, + } + }); + // Full forks reuse the parent's reference context instead of rebuilding it. If that + // context omitted the parent's developer fragment, append the child's override so its + // instructions still reach the model exactly once. + if let Some(subagent_developer_instructions) = subagent_developer_instructions.as_ref() + && preserve_reference_context_item + && !replaced_parent_developer_instructions + && !subagent_developer_instructions.is_empty() + && parent_thread + .session + .reference_context_item() + .await + .is_some() + && let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_developer_instructions.clone(), + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(developer_message)); + } if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 && let Some(subagent_usage_hint_text) = diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index af3e1fd474..1e5cf0a87f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1216,6 +1216,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { Some("Parent subagent guidance.".to_string()); let mut child_config = harness.config.clone(); let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.developer_instructions = Some("Child developer instructions.".to_string()); + child_config.multi_agent_v2.subagent_developer_instructions = + Some("Child developer instructions.".to_string()); 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 = @@ -1275,7 +1278,8 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { role: "developer".to_string(), content: vec![ ContentItem::InputText { - text: "Parent developer instructions.".to_string(), + text: "Developer context before.\nParent developer instructions.\nDeveloper context after." + .to_string(), }, ContentItem::InputText { text: "Preserved developer context.".to_string(), @@ -1353,7 +1357,8 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { role: "developer".to_string(), content: vec![ ContentItem::InputText { - text: "Parent developer instructions.".to_string(), + text: "Developer context before.\nChild developer instructions.\nDeveloper context after." + .to_string(), }, ContentItem::InputText { text: "Preserved developer context.".to_string(), @@ -1392,6 +1397,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let mut no_hint_child_config = harness.config.clone(); let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2); + no_hint_child_config.developer_instructions = Some(String::new()); + no_hint_child_config + .multi_agent_v2 + .subagent_developer_instructions = Some(String::new()); no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = None; let no_hint_child_thread_id = harness .control @@ -1425,15 +1434,22 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "full-history forked child should not add empty subagent guidance" ); assert!( - history_contains_text( + !history_contains_text( no_hint_history.raw_items(), "Parent developer instructions." ), - "full-history forked child should inherit parent developer instructions" + "empty child developer instructions should remove parent developer instructions" + ); + assert!( + history_contains_text( + no_hint_history.raw_items(), + "Developer context before.\n\nDeveloper context after." + ), + "empty child developer instructions should preserve surrounding developer context" ); assert!( history_contains_text(no_hint_history.raw_items(), "Preserved developer context."), - "full-history forked child should preserve unrelated developer fragments" + "empty child developer instructions should preserve unrelated developer fragments" ); let expected = ( @@ -1484,6 +1500,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { Some("Parent subagent guidance.".to_string()); let mut child_config = harness.config.clone(); let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.developer_instructions = Some("Child developer instructions.".to_string()); + child_config.multi_agent_v2.subagent_developer_instructions = + Some("Child developer instructions.".to_string()); 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 = @@ -1521,7 +1540,8 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { role: "developer".to_string(), content: vec![ ContentItem::InputText { - text: "Parent developer instructions.".to_string(), + text: "Compacted context before.\nParent developer instructions.\nCompacted context after." + .to_string(), }, ContentItem::InputText { text: "Preserved compacted developer context.".to_string(), @@ -1590,8 +1610,15 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { "forked child history should strip stale parent hints from compacted replacement history" ); assert!( - history_contains_text(history.raw_items(), "Parent developer instructions."), - "forked child history should inherit compacted parent developer instructions" + !history_contains_text(history.raw_items(), "Parent developer instructions."), + "forked child history should replace parent instructions in compacted replacement history" + ); + assert!( + history_contains_text( + history.raw_items(), + "Compacted context before.\nChild developer instructions.\nCompacted context after." + ), + "forked child history should replace compacted parent instructions without removing surrounding context" ); assert!( history_contains_text( @@ -1616,6 +1643,301 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { .expect("parent shutdown should submit"); } +/// Full-history forks must restore child instructions when compaction discarded +/// the only matching parent instruction fragment from effective history. +#[tokio::test] +async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_parent_fragment() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); + let mut child_config = parent_config.clone(); + child_config.developer_instructions = Some("Child developer instructions.".to_string()); + child_config.multi_agent_v2.subagent_developer_instructions = + Some("Child developer instructions.".to_string()); + + let new_thread = harness + .manager + .start_thread(StartThreadOptions::new(parent_config)) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + let turn_context = parent_thread.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-compacted-stale-instructions".to_string(); + let replacement_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "compacted parent summary".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Preserved compacted developer context.".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ]; + + // Preserve the parent's live baseline while its durable checkpoint omits the + // developer fragment that appeared in obsolete pre-compaction history. + parent_thread + .session + .replace_history( + replacement_history.clone(), + Some(turn_context.to_turn_context_item()), + ) + .await; + parent_thread + .session + .persist_rollout_items(&[ + RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent developer instructions.".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }), + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(replacement_history), + window_number: None, + first_window_id: None, + previous_window_id: None, + window_id: None, + }), + RolloutItem::TurnContext(turn_context.to_turn_context_item()), + RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)), + ]) + .await; + parent_thread.session.ensure_rollout_materialized().await; + parent_thread + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should preserve effective compacted instructions") + .thread_id; + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.session.clone_history().await; + assert!( + history_contains_text( + history.raw_items(), + "Preserved compacted developer context." + ), + "full-history fork should preserve unrelated compacted developer fragments" + ); + assert!( + !history_contains_text(history.raw_items(), "Parent developer instructions."), + "full-history fork should not restore stale pre-compaction parent instructions" + ); + assert!( + history_contains_text(history.raw_items(), "Child developer instructions."), + "full-history fork should append child instructions absent from effective compacted history" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +/// A legacy compaction clears the child's baseline, so its first turn must +/// rebuild configured developer instructions exactly once. +#[tokio::test] +async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_once() { + for (case, parent_developer_instructions) in [ + ("without parent instructions", None), + ( + "with parent instructions", + Some("Parent developer instructions."), + ), + ] { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.developer_instructions = parent_developer_instructions.map(str::to_string); + let mut child_config = parent_config.clone(); + child_config.developer_instructions = Some("Child developer instructions.".to_string()); + child_config.multi_agent_v2.subagent_developer_instructions = + Some("Child developer instructions.".to_string()); + + let new_thread = harness + .manager + .start_thread(StartThreadOptions::new(parent_config)) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + let turn_context = parent_thread.session.new_default_turn().await; + let parent_spawn_call_id = match parent_developer_instructions { + Some(_) => "spawn-call-legacy-compact-with-parent", + None => "spawn-call-legacy-compact-without-parent", + }; + let parent_user_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "parent task before legacy compaction".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }; + + // A live parent can reestablish its baseline after resuming a rollout + // whose older compaction record cannot restore that baseline to a child. + parent_thread + .session + .replace_history( + vec![parent_user_message.clone()], + Some(turn_context.to_turn_context_item()), + ) + .await; + let mut rollout_items = vec![ + RolloutItem::ResponseItem(parent_user_message), + RolloutItem::Compacted(CompactedItem { + message: "legacy compacted summary".to_string(), + replacement_history: None, + window_number: None, + first_window_id: None, + previous_window_id: None, + window_id: None, + }), + ]; + if let Some(instructions) = parent_developer_instructions { + rollout_items.push(RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: instructions.to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + })); + } + rollout_items.push(RolloutItem::TurnContext( + turn_context.to_turn_context_item(), + )); + rollout_items.push(RolloutItem::ResponseItem(spawn_agent_call( + parent_spawn_call_id, + ))); + parent_thread + .session + .persist_rollout_items(&rollout_items) + .await; + parent_thread.session.ensure_rollout_materialized().await; + parent_thread + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.to_string()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should preserve legacy compacted history") + .thread_id; + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + while child_thread + .session + .reference_context_item() + .await + .is_none() + { + tokio::task::yield_now().await; + } + let history = child_thread.session.clone_history().await; + let mut instruction_count = 0; + for item in history.raw_items() { + let ResponseItem::Message { role, content, .. } = item else { + continue; + }; + if role != "developer" { + continue; + } + for content_item in content { + if let ContentItem::InputText { text } = content_item + && text == "Child developer instructions." + { + instruction_count += 1; + } + } + } + assert_eq!( + instruction_count, 1, + "{case}: canonical context reconstruction must not duplicate child developer instructions" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); + } +} + #[tokio::test] async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { let harness = AgentControlHarness::new().await; @@ -1934,6 +2256,9 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { Some("Parent root guidance.".to_string()); let mut child_config = harness.config.clone(); let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.developer_instructions = Some("Child developer instructions.".to_string()); + child_config.multi_agent_v2.subagent_developer_instructions = + Some("Child developer instructions.".to_string()); child_config.multi_agent_v2.subagent_usage_hint_text = Some("Child subagent guidance.".to_string()); let new_thread = harness @@ -2024,8 +2349,12 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { "bounded fork should strip stale parent root hints before the child rebuilds startup context" ); assert!( - history_contains_text(history.raw_items(), "Parent developer instructions."), - "bounded fork should inherit parent developer instructions from retained turns" + !history_contains_text(history.raw_items(), "Parent developer instructions."), + "bounded fork should remove parent instructions before the child rebuilds startup context" + ); + assert!( + !history_contains_text(history.raw_items(), "Child developer instructions."), + "bounded fork should not inject child instructions before its canonical context rebuild" ); assert!( history_contains_text(history.raw_items(), "Preserved bounded developer context."), diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 3fc20a33ae..1e14f789e6 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -38,6 +38,41 @@ const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not availabl pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, +) -> Result<(), String> { + apply_role_to_config_with_developer_instructions( + config, + role_name, + RoleDeveloperInstructions::UseConfigLayers, + ) + .await +} + +/// Applies a v2 role without losing developer instructions selected by its caller. +/// +/// A role's own top-level developer instructions still take precedence. When its role file omits +/// that setting, rebuilding the config must not restore inherited instructions from older layers. +pub(crate) async fn apply_role_to_config_for_multi_agent_v2( + config: &mut Config, + role_name: Option<&str>, +) -> Result<(), String> { + apply_role_to_config_with_developer_instructions( + config, + role_name, + RoleDeveloperInstructions::PreserveCallerInstructions, + ) + .await +} + +#[derive(Clone, Copy)] +enum RoleDeveloperInstructions { + UseConfigLayers, + PreserveCallerInstructions, +} + +async fn apply_role_to_config_with_developer_instructions( + config: &mut Config, + role_name: Option<&str>, + developer_instructions: RoleDeveloperInstructions, ) -> Result<(), String> { let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); @@ -45,7 +80,7 @@ pub(crate) async fn apply_role_to_config( .cloned() .ok_or_else(|| format!("unknown agent_type '{role_name}'"))?; - apply_role_to_config_inner(config, role_name, &role) + apply_role_to_config_inner(config, role_name, &role, developer_instructions) .await .map_err(|err| { tracing::warn!("failed to apply role to config: {err}"); @@ -57,6 +92,7 @@ async fn apply_role_to_config_inner( config: &mut Config, role_name: &str, role: &AgentRoleConfig, + developer_instructions: RoleDeveloperInstructions, ) -> anyhow::Result<()> { let is_built_in = !config.agent_roles.contains_key(role_name); let Some(config_file) = role.config_file.as_ref() else { @@ -75,6 +111,7 @@ async fn apply_role_to_config_inner( *config = reload::build_next_config( config, role_layer_toml, + developer_instructions, preserve_current_provider, preserve_current_service_tier, ) @@ -132,24 +169,35 @@ mod reload { pub(super) async fn build_next_config( config: &Config, role_layer_toml: TomlValue, + developer_instructions: RoleDeveloperInstructions, preserve_current_provider: bool, preserve_current_service_tier: bool, ) -> anyhow::Result { let preserve_current_model = role_layer_toml.get("model").is_none(); let preserve_current_reasoning_effort = role_layer_toml.get("model_reasoning_effort").is_none(); + let mut overrides = reload_overrides( + config, + preserve_current_model, + preserve_current_provider, + preserve_current_service_tier, + ); + if let (RoleDeveloperInstructions::PreserveCallerInstructions, Some(_), None) = ( + developer_instructions, + &config.multi_agent_v2.subagent_developer_instructions, + role_layer_toml.get("developer_instructions"), + ) { + overrides + .developer_instructions + .clone_from(&config.developer_instructions); + } let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?; let merged_config = deserialize_effective_config(config, &config_layer_stack)?; let mut next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, - reload_overrides( - config, - preserve_current_model, - preserve_current_provider, - preserve_current_service_tier, - ), + overrides, config.codex_home.clone(), config_layer_stack, ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 75288ca52b..448d7a22ef 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10762,6 +10762,7 @@ default_wait_timeout_ms = 30000 usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +subagent_developer_instructions = " Delegate carefully. " multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true @@ -10804,6 +10805,13 @@ max_concurrent_threads_per_session = 9 config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config + .multi_agent_v2 + .subagent_developer_instructions + .as_deref(), + Some("Delegate carefully.") + ); assert_eq!( config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(), Some("Custom mode guidance.") @@ -10965,11 +10973,13 @@ fn multi_agent_v2_preserves_empty_mode_hint_override() { let config_toml = toml::from_str( r#"[features.multi_agent_v2] multi_agent_mode_hint_text = "" +subagent_developer_instructions = " \t " "#, ) .expect("multi-agent v2 config should parse"); let expected = MultiAgentV2Config { + subagent_developer_instructions: Some(String::new()), multi_agent_mode_hint_text: Some(String::new()), ..resolve_multi_agent_v2_config(&ConfigToml::default()) }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0289b65bde..652d4e247a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1250,6 +1250,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub subagent_developer_instructions: Option, pub multi_agent_mode_hint_text: Option, pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, @@ -1274,6 +1275,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), + subagent_developer_instructions: None, multi_agent_mode_hint_text: None, tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, @@ -2716,6 +2718,9 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config base.map(|config| &config.subagent_usage_hint_text), default_subagent_usage_hint_text, ); + let subagent_developer_instructions = base + .and_then(|config| config.subagent_developer_instructions.as_ref()) + .map(|instructions| instructions.trim().to_string()); let multi_agent_mode_hint_text = base .and_then(|config| config.multi_agent_mode_hint_text.as_ref()) .cloned() @@ -2736,6 +2741,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + subagent_developer_instructions, multi_agent_mode_hint_text, tool_namespace, hide_spawn_agent_metadata, diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index f01cab93f5..bbc2e201d6 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -262,6 +262,8 @@ mod tests { async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; let mut config = (*sc.original_config_do_not_use).clone(); + config.multi_agent_v2.subagent_developer_instructions = + Some("Locked subagent developer instructions.".to_string()); config.token_budget = Some(crate::config::TokenBudgetConfig { reminder_threshold_tokens: Some(16_000), reminder_message_template: "Locked reminder: {n_remaining} tokens.".to_string(), @@ -346,9 +348,10 @@ mod tests { min_wait_timeout_ms: Some(_), max_wait_timeout_ms: Some(_), default_wait_timeout_ms: Some(_), + subagent_developer_instructions: Some(instructions), hide_spawn_agent_metadata: Some(_), .. - }) + }) if instructions == "Locked subagent developer instructions." )); assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 9bb7f76f9d..f8569c441e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,4 +1,5 @@ use crate::agent::role::apply_role_to_config; +use crate::agent::role::apply_role_to_config_for_multi_agent_v2; use crate::config::Config; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; @@ -200,6 +201,15 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Result<(), FunctionCallError> { let previous_model = config.model.clone(); let previous_reasoning_effort = config.model_reasoning_effort.clone(); - apply_role_to_config(config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; + if session.multi_agent_version() == Some(MultiAgentVersion::V2) { + apply_role_to_config_for_multi_agent_v2(config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } else { + apply_role_to_config(config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } if config.model == previous_model && config.model_reasoning_effort == previous_reasoning_effort { return Ok(()); diff --git a/codex-rs/core/tests/suite/multi_agent_resume.rs b/codex-rs/core/tests/suite/multi_agent_resume.rs index 2a372e578e..c2bd591a18 100644 --- a/codex-rs/core/tests/suite/multi_agent_resume.rs +++ b/codex-rs/core/tests/suite/multi_agent_resume.rs @@ -30,6 +30,7 @@ const ROLE_NAME: &str = "durable_worker"; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_MODEL_PROVIDER_ID: &str = "mock"; const ROLE_DEVELOPER_INSTRUCTIONS: &str = "Keep the durable worker role configuration."; +const SUBAGENT_DEVELOPER_INSTRUCTIONS: &str = "Use the default durable worker instructions."; fn decoded_body(request: &wiremock::Request) -> Option> { let is_zstd = request @@ -77,6 +78,8 @@ fn configure_multi_agent_v2_with_role( .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config.multi_agent_v2.subagent_developer_instructions = + Some(SUBAGENT_DEVELOPER_INSTRUCTIONS.to_string()); let role_path = config.codex_home.join("durable-worker-role.toml"); std::fs::write( &role_path, @@ -192,6 +195,7 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu assert!(initial_child_request.requests().iter().any(|request| { request.body_contains_text(INITIAL_TASK) && request.body_contains_text(ROLE_DEVELOPER_INSTRUCTIONS) + && !request.body_contains_text(SUBAGENT_DEVELOPER_INSTRUCTIONS) })); let initial_worker_config = worker_thread.config_snapshot().await; let initial_worker_role_config = ( @@ -300,6 +304,7 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu assert!(followup_child_request.requests().iter().any(|request| { request.body_contains_text(FOLLOWUP_TASK) && request.body_contains_text(ROLE_DEVELOPER_INSTRUCTIONS) + && !request.body_contains_text(SUBAGENT_DEVELOPER_INSTRUCTIONS) })); let reloaded_worker_config = reloaded_worker.config_snapshot().await; let reloaded_worker_role_config = ( diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 75d286563e..02c554ed7e 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -95,6 +95,9 @@ pub struct MultiAgentV2ConfigToml { pub root_agent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, + /// Overrides inherited developer instructions for subagents without role-specific instructions. + #[serde(skip_serializing_if = "Option::is_none")] + pub subagent_developer_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub multi_agent_mode_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 53bc26c3bb..ce9e6b29ae 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -444,6 +444,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +subagent_developer_instructions = "Delegate carefully." multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true @@ -470,6 +471,7 @@ non_code_mode_only = true 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()), + subagent_developer_instructions: Some("Delegate carefully.".to_string()), multi_agent_mode_hint_text: Some("Custom mode guidance.".to_string()), tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), @@ -538,6 +540,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: Some(false), min_wait_timeout_ms: Some(2500), + subagent_developer_instructions: Some("Delegate carefully.".to_string()), ..Default::default() })), network_proxy: Some(FeatureToml::Config(crate::NetworkProxyConfigToml { @@ -578,6 +581,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: Some(true), min_wait_timeout_ms: Some(2500), + subagent_developer_instructions: Some("Delegate carefully.".to_string()), ..Default::default() })) );