From 395723b2385cf1314dcf27db4b01a221785109b0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 14 Aug 2026 18:16:07 +0000 Subject: [PATCH] Source multi-agent instructions from the model catalog (#38619) ## What changed - Add model-catalog messages for root and subagent roles, explicit delegation, and delegation hints. - Resolve role instructions in config, catalog, then bundled-default order, while preserving empty values as an explicit way to suppress fallback text. - Refresh catalog-provided role and mode instructions when the model changes, and give full-history forks the selected child model's subagent role without retaining the parent's role guidance. - Keep existing config overrides and reasoning-effort behavior, including proactive delegation for ultra reasoning effort. ## Testing - Cover message deserialization and preservation through model overrides. - Cover precedence, empty overrides, model switches, resumed sessions, and full-history subagent forks. GitOrigin-RevId: 4625cf7c6a5490176adddfaa0fb99100707daea9 --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/core/src/agent/control.rs | 2 + codex-rs/core/src/agent/control/spawn.rs | 48 ++--- codex-rs/core/src/agent/control_tests.rs | 32 +++- codex-rs/core/src/config/config_tests.rs | 115 +++++++++--- codex-rs/core/src/config/mod.rs | 131 +------------- codex-rs/core/src/context/mod.rs | 2 + .../context/multi_agent_role_instructions.rs | 49 +++++ .../world_state/multi_agent_mode_tests.rs | 37 +++- .../world_state/multi_agent_usage_hint.rs | 19 +- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/event_mapping.rs | 1 + codex-rs/core/src/guardian/review_session.rs | 3 + codex-rs/core/src/session/mod.rs | 6 + codex-rs/core/src/session/multi_agents.rs | 160 +++++++++++++--- codex-rs/core/src/session/tests.rs | 9 +- .../src/tools/handlers/multi_agents/spawn.rs | 1 + .../tools/handlers/multi_agents_v2/spawn.rs | 26 +++ .../suite/catalog_permission_messages.rs | 1 + .../tests/suite/collaboration_instructions.rs | 1 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/multi_agent_mode.rs | 171 ++++++++++++++++-- .../core/tests/suite/permissions_messages.rs | 3 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + .../tests/suite/spawn_agent_description.rs | 32 ++-- .../tests/suite/subagent_notifications.rs | 34 ++++ codex-rs/models-manager/src/model_info.rs | 3 + .../models-manager/src/model_info_tests.rs | 19 ++ codex-rs/protocol/src/openai_models.rs | 52 ++++++ 30 files changed, 734 insertions(+), 231 deletions(-) create mode 100644 codex-rs/core/src/context/multi_agent_role_instructions.rs diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 2f060aadde..51d1700ef7 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -40,6 +40,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 900e2fa6ee..d205f705aa 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -15,6 +15,7 @@ use crate::config::RolloutBudgetConfig; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::rollout_budget::RolloutBudget; use crate::session::emit_subagent_session_started; +use crate::session::multi_agents::ResolvedMultiAgentV2UsageHints; use crate::session_prefix::format_inter_agent_completion_message; use crate::session_prefix::format_subagent_context_line; use crate::session_prefix::format_subagent_notification_message; @@ -79,6 +80,7 @@ pub(crate) struct SpawnAgentOptions { pub(crate) parent_turn_id: Option, pub(crate) root_turn_id: Option, pub(crate) environments: Option>, + pub(crate) multi_agent_v2_usage_hints: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3bbdc5afc5..2ab962ee9d 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -4,6 +4,8 @@ use crate::agent::role::apply_role_to_config_for_multi_agent_v2; use crate::config::PermissionProfileSnapshot; use crate::context::ContextualUserFragment; use crate::context::CurrentTimeReminder; +use crate::context::MultiAgentRoleInstructions; +use crate::session::multi_agents::resolve_usage_hints; use codex_extension_api::ExtensionDataInit; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); @@ -94,7 +96,8 @@ fn is_fork_excluded_developer_message(item: &ResponseItem, usage_hint_texts: &[S return false; }; - CurrentTimeReminder::matches_text(text) + MultiAgentRoleInstructions::matches_text(text) + || CurrentTimeReminder::matches_text(text) || usage_hint_texts .iter() .any(|usage_hint_text| usage_hint_text == text) @@ -684,19 +687,13 @@ impl AgentControl { let multi_agent_v2_usage_hint_texts_to_filter: Vec = if multi_agent_version == MultiAgentVersion::V2 { let parent_config = parent_thread.session.get_config().await; - [ - parent_config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone(), - parent_config - .multi_agent_v2 - .subagent_usage_hint_text - .clone(), - ] - .into_iter() - .flatten() - .collect() + let parent_usage_hints = + resolve_usage_hints(&parent_config.multi_agent_v2, /*catalog*/ None); + [parent_usage_hints.root, parent_usage_hints.subagent] + .into_iter() + .flatten() + .map(|instructions| instructions.render()) + .collect() } else { Vec::new() }; @@ -792,12 +789,17 @@ impl AgentControl { } true } + RolloutItem::WorldState(world_state) => { + if multi_agent_version == MultiAgentVersion::V2 { + world_state.state.remove("multi_agent_usage_hint"); + } + true + } RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) | RolloutItem::InterAgentCommunication(_) - | RolloutItem::InterAgentCommunicationMetadata { .. } - | RolloutItem::WorldState(_) => true, + | RolloutItem::InterAgentCommunicationMetadata { .. } => true, RolloutItem::SecurityRiskScore(_) => false, } }); @@ -822,13 +824,15 @@ impl AgentControl { } if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 - && let Some(subagent_usage_hint_text) = - config.multi_agent_v2.subagent_usage_hint_text.clone() - && let Some(subagent_usage_hint_message) = - crate::context_manager::updates::build_developer_update_item(vec![ - subagent_usage_hint_text, - ]) + && let Some(subagent_usage_hint) = options + .multi_agent_v2_usage_hints + .as_ref() + .map(|hints| hints.subagent.clone()) + .unwrap_or_else(|| { + resolve_usage_hints(&config.multi_agent_v2, /*catalog*/ None).subagent + }) { + let subagent_usage_hint_message = ContextualUserFragment::into(subagent_usage_hint); forked_rollout_items.push(RolloutItem::ResponseItem( subagent_usage_hint_message.into(), )); diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 59cd408205..f09a3293c1 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -9,6 +9,7 @@ use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigBuilder; use crate::context::ContextualUserFragment; +use crate::context::MultiAgentRoleInstructions; use crate::context::SubagentNotification; use crate::init_state_db; use crate::thread_manager::StartThreadOptions; @@ -1509,7 +1510,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { 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; + no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = Some(String::new()); let no_hint_child_thread_id = harness .control .spawn_agent_with_metadata( @@ -1538,8 +1539,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("no-hint child thread should be registered"); let no_hint_history = no_hint_child_thread.session.clone_history().await; assert!( - !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance."), - "full-history forked child should not add empty subagent guidance" + !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance.") + && !history_contains_text( + no_hint_history.raw_items(), + "You are an agent in a team of agents" + ), + "full-history forked child should not add configured or bundled subagent guidance" ); assert!( !history_contains_text( @@ -1623,6 +1628,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { phase: None, internal_chat_message_metadata_passthrough: None, }, + ContextualUserFragment::into(MultiAgentRoleInstructions::catalog( + "Catalog parent root guidance.", + )), parent_task.to_model_input_item(), ResponseItem::Message { id: None, @@ -1691,6 +1699,12 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id), fork_mode: Some(SpawnAgentForkMode::FullHistory), + multi_agent_v2_usage_hints: Some(ResolvedMultiAgentV2UsageHints { + root: None, + subagent: Some(MultiAgentRoleInstructions::catalog( + "Catalog child subagent guidance.", + )), + }), ..Default::default() }, ) @@ -1708,6 +1722,14 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { history_contains_text(history.raw_items(), "compacted parent summary"), "forked child history should retain compacted non-hint content" ); + assert!( + !history_contains_text(history.raw_items(), "Catalog parent root guidance."), + "forked child history should strip the resolved parent hint from compacted replacement history" + ); + assert!( + history_contains_text(history.raw_items(), "Catalog child subagent guidance."), + "full-history forked child should add the resolved child hint after compacted-history sanitization" + ); assert!( !history .raw_items() @@ -1736,10 +1758,6 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { ), "forked child history should preserve unrelated compacted developer fragments" ); - assert!( - history_contains_text(history.raw_items(), "Child subagent guidance."), - "full-history forked child should add the child subagent hint after compacted-history sanitization" - ); let _ = harness .control diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 133d98fd3d..b32181c988 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,7 +1,9 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; +use crate::context::ContextualUserFragment; use crate::plugins::plugins_manager_for_config; +use crate::session::multi_agents::resolve_usage_hints; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerEntry; @@ -87,6 +89,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -10991,17 +10994,29 @@ max_concurrent_threads_per_session = 17 let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; - let expected_suffix = format!( - "{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n{concurrency_guidance}" - ); - assert!( - [ - config.root_agent_usage_hint_text, - config.subagent_usage_hint_text, - ] - .into_iter() - .all(|hint| hint.is_some_and(|hint| hint.contains(expected_suffix.as_str()))) + assert!(config.wait_agent_enabled); + for wait_agent_enabled in [true, false] { + let mut config = config.clone(); + config.wait_agent_enabled = wait_agent_enabled; + let usage_hints = resolve_usage_hints(&config, /*catalog*/ None); + for hint in [usage_hints.root, usage_hints.subagent] { + let hint = hint.expect("default usage hints should be present").body(); + assert!(hint.contains(concurrency_guidance)); + assert_eq!( + hint.contains("When calling `wait_agent`, prefer longer waits"), + wait_agent_enabled + ); + } + } + + let usage_hints = resolve_usage_hints( + &config, + Some(&MultiAgentRoleMessages { + root: Some(String::new()), + subagent: Some(String::new()), + }), ); + assert!(usage_hints.root.is_none() && usage_hints.subagent.is_none()); } #[test] @@ -11026,6 +11041,23 @@ expose_spawn_agent_model_overrides = true config.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + let usage_hints = resolve_usage_hints( + &config, + Some(&MultiAgentRoleMessages { + root: Some("Catalog root base.".to_string()), + subagent: Some("Catalog subagent base.".to_string()), + }), + ); + assert_eq!( + ( + usage_hints.root.map(|hint| hint.body()), + usage_hints.subagent.map(|hint| hint.body()), + ), + ( + Some("Root guidance.".to_string()), + Some("Subagent guidance.".to_string()), + ) + ); } #[test] @@ -11033,18 +11065,39 @@ fn multi_agent_v2_exposes_model_overrides_by_default() { let config_toml = toml::from_str(r#"[features.multi_agent_v2]"#).expect("multi-agent v2 config should parse"); - let config = resolve_multi_agent_v2_config(&config_toml); + let mut config = resolve_multi_agent_v2_config(&config_toml); assert!(config.expose_spawn_agent_model_overrides); - assert!( - [ - config.root_agent_usage_hint_text, - config.subagent_usage_hint_text, - ] - .into_iter() - .all(|hint| hint.is_some_and(|hint| { - hint.ends_with(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT) - })) - ); + let usage_hints = resolve_usage_hints(&config, /*catalog*/ None); + config.expose_spawn_agent_model_overrides = false; + let usage_hints_without_model_overrides = resolve_usage_hints(&config, /*catalog*/ None); + + for (hint, hint_without_model_overrides) in [ + (usage_hints.root, usage_hints_without_model_overrides.root), + ( + usage_hints.subagent, + usage_hints_without_model_overrides.subagent, + ), + ] { + let hint = hint.expect("default usage hints should be present").body(); + let hint_without_model_overrides = hint_without_model_overrides + .expect("default usage hints should be present without model overrides") + .body(); + + let model_override_guidance = hint + .strip_prefix(hint_without_model_overrides.as_str()) + .expect("model-override guidance should extend the base usage hint"); + for required_fragment in [ + "Full-history forks", + "`fork_turns`", + "`model`", + "`reasoning_effort`", + ] { + assert!( + model_override_guidance.contains(required_fragment), + "model-override guidance should contain {required_fragment}" + ); + } + } } #[tokio::test] @@ -11108,7 +11161,7 @@ subagent_developer_instructions = " \t " } #[tokio::test] -async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> { +async fn multi_agent_v2_empty_usage_hint_overrides_are_preserved() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -11125,8 +11178,22 @@ subagent_usage_hint_text = "" .build() .await?; - assert_eq!(config.multi_agent_v2.root_agent_usage_hint_text, None); - assert_eq!(config.multi_agent_v2.subagent_usage_hint_text, None); + let usage_hints = resolve_usage_hints( + &config.multi_agent_v2, + Some(&MultiAgentRoleMessages { + root: Some("catalog root".to_string()), + subagent: Some("catalog subagent".to_string()), + }), + ); + assert_eq!( + ( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + usage_hints.root, + usage_hints.subagent, + ), + (Some(""), Some(""), None, None) + ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3271170dc8..07f1bdfbeb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -213,69 +213,7 @@ pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usiz pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000; pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; pub(crate) const DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. - -At the start of your turn, you are the active agent. -You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. -All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. - -You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. -Child agents can also spawn their own sub-agents. -You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. - -You will receive messages in the analysis channel in the form: -``` -Message Type: MESSAGE | FINAL_ANSWER -Task name: -Sender: -Payload: - -``` -They may be addressed as to=/root -"#; -const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task. - -You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. - -You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. -Child agents can also spawn their own sub-agents. - -When you provide a response in the final channel, that content is immediately delivered back to your parent agent. - -You will receive messages in the analysis channel in the form: -``` -Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER -Task name: -Sender: -Payload: - -``` -You may also see them addressed as to=/root/..., which indicates your identity is /root/... -"#; -const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; -const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str = - "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; -const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. - -All agents share the same directory. In detail: -- All agents have access to the same container and filesystem as you. -- All agents use the same current working directory. -- As a result, edits made by one agent are immediately visible to all other agents. -"#; -fn default_multi_agent_v2_usage_hint_text( - usage_hint_text: &str, - max_concurrency: usize, - wait_agent_usage_hint_text: Option<&str>, -) -> String { - let wait_agent_usage_hint_text = match wait_agent_usage_hint_text { - Some(wait_agent_usage_hint_text) => format!("{wait_agent_usage_hint_text}\n\n"), - None => String::new(), - }; - format!( - "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_usage_hint_text}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." - ) -} pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0; pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 = @@ -1298,16 +1236,8 @@ impl MultiAgentV2Config { max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, usage_hint_text: None, - root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( - DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, - max_concurrent_threads_per_session, - Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT), - )), - subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( - DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, - max_concurrent_threads_per_session, - Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT), - )), + root_agent_usage_hint_text: None, + subagent_usage_hint_text: None, subagent_developer_instructions: None, multi_agent_mode_hint_text: None, tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), @@ -2725,42 +2655,15 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let expose_spawn_agent_model_overrides = base .and_then(|config| config.expose_spawn_agent_model_overrides) .unwrap_or(default.expose_spawn_agent_model_overrides); + let root_agent_usage_hint_text = base + .and_then(|config| config.root_agent_usage_hint_text.as_ref()) + .cloned(); + let subagent_usage_hint_text = base + .and_then(|config| config.subagent_usage_hint_text.as_ref()) + .cloned(); let wait_agent_enabled = base .and_then(|config| config.wait_agent_enabled) .unwrap_or(default.wait_agent_enabled); - let default_wait_agent_usage_hint_text = if wait_agent_enabled { - Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT) - } else { - None - }; - let mut default_root_agent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text( - DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, - max_concurrent_threads_per_session, - default_wait_agent_usage_hint_text, - )); - let mut default_subagent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text( - DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, - max_concurrent_threads_per_session, - default_wait_agent_usage_hint_text, - )); - if expose_spawn_agent_model_overrides { - default_root_agent_usage_hint_text = Some(append_usage_hint_text( - default_root_agent_usage_hint_text.as_deref(), - DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT, - )); - default_subagent_usage_hint_text = Some(append_usage_hint_text( - default_subagent_usage_hint_text.as_deref(), - DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT, - )); - } - let root_agent_usage_hint_text = resolve_optional_prompt_text( - base.map(|config| &config.root_agent_usage_hint_text), - default_root_agent_usage_hint_text, - ); - let subagent_usage_hint_text = resolve_optional_prompt_text( - 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()); @@ -2941,24 +2844,6 @@ fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalRe } } -fn resolve_optional_prompt_text( - configured: Option<&Option>, - default: Option, -) -> Option { - match configured { - Some(Some(value)) if value.is_empty() => None, - Some(Some(value)) => Some(value.clone()), - Some(None) | None => default, - } -} - -fn append_usage_hint_text(usage_hint_text: Option<&str>, additional_text: &str) -> String { - match usage_hint_text { - Some(usage_hint_text) => format!("{usage_hint_text}\n\n{additional_text}"), - None => additional_text.to_string(), - } -} - fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> { match features?.code_mode.as_ref()? { FeatureToml::Enabled(_) => None, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index bd9d6c580a..4f69daa3ba 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -18,6 +18,7 @@ mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; mod multi_agent_mode_instructions; +mod multi_agent_role_instructions; mod multi_agent_usage_hint; mod network_rule_saved; mod node_repl_review_evidence; @@ -63,6 +64,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; +pub(crate) use multi_agent_role_instructions::MultiAgentRoleInstructions; pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint; pub(crate) use network_rule_saved::NetworkRuleSaved; pub(crate) use node_repl_review_evidence::NodeReplReviewEvidence; diff --git a/codex-rs/core/src/context/multi_agent_role_instructions.rs b/codex-rs/core/src/context/multi_agent_role_instructions.rs new file mode 100644 index 0000000000..66c96afd59 --- /dev/null +++ b/codex-rs/core/src/context/multi_agent_role_instructions.rs @@ -0,0 +1,49 @@ +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MultiAgentRoleInstructions { + text: String, + marked: bool, +} + +impl MultiAgentRoleInstructions { + pub(crate) fn unmarked(text: impl Into) -> Self { + Self { + text: text.into(), + marked: false, + } + } + + pub(crate) fn catalog(text: impl Into) -> Self { + Self { + text: text.into(), + marked: true, + } + } +} + +impl ContextualUserFragment for MultiAgentRoleInstructions { + fn role(&self) -> &'static str { + "developer" + } + + fn requires_separate_message(&self) -> bool { + true + } + + fn markers(&self) -> (&'static str, &'static str) { + if self.marked { + Self::type_markers() + } else { + ("", "") + } + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + self.text.clone() + } +} diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs index 75577d120a..49c67a43f1 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs @@ -1,5 +1,6 @@ use super::super::test_support::render_section_cases; use super::*; +use crate::context::MultiAgentRoleInstructions; use crate::context::world_state::WorldState; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::approx_token_count; @@ -70,7 +71,9 @@ fn persisted_mode_is_restored_only_when_missing_from_history() { fn unchanged_mode_is_reemitted_after_usage_hint_migration() { let previous = state(Some(MultiAgentMode::Proactive)); let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint( - &MultiAgentUsageHintState::new("Current usage instructions."), + &MultiAgentUsageHintState::new(MultiAgentRoleInstructions::unmarked( + "Current usage instructions.", + )), ); let instructions = current @@ -85,6 +88,38 @@ fn unchanged_mode_is_reemitted_after_usage_hint_migration() { ); } +#[test] +fn catalog_role_updates_remain_separate_from_active_mode() { + let previous_hint = + MultiAgentUsageHintState::new(MultiAgentRoleInstructions::catalog("Previous role.")); + let previous_mode = + MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(&previous_hint); + let mut previous = WorldState::default(); + previous.add_section(previous_hint); + previous.add_section(previous_mode); + + let current_role = MultiAgentRoleInstructions::catalog("Current role."); + let current_hint = MultiAgentUsageHintState::new(current_role.clone()); + let current_mode = + MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(¤t_hint); + let mut current = WorldState::default(); + current.add_section(current_hint); + current.add_section(current_mode); + + let updates = crate::context_manager::updates::merge_contextual_fragments( + current.render_diff(&previous.snapshot()), + ); + let expected_mode = MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive) + .expect("proactive mode should render"); + assert_eq!( + updates, + vec![ + ContextualUserFragment::into(current_role), + ContextualUserFragment::into(expected_mode), + ], + ); +} + #[test] fn custom_mode_is_bounded_before_snapshot_and_rendering() { let state = state(Some(MultiAgentMode::Custom("custom mode ".repeat(1_000)))); diff --git a/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs index 4142831916..acdde8a9f6 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs @@ -2,19 +2,18 @@ use super::PreviousSectionState; use super::WorldStateHash; use super::WorldStateSection; use crate::context::ContextualUserFragment; +use crate::context::MultiAgentRoleInstructions; use crate::context::MultiAgentUsageHint; -/// Configured multi-agent instructions currently visible to the model. +/// Configured or model-owned multi-agent instructions currently visible to the model. #[derive(Clone, Debug)] pub(crate) struct MultiAgentUsageHintState { - instructions: MultiAgentUsageHint, + instructions: MultiAgentRoleInstructions, } impl MultiAgentUsageHintState { - pub(crate) fn new(text: &str) -> Self { - Self { - instructions: MultiAgentUsageHint::new(text), - } + pub(crate) fn new(instructions: MultiAgentRoleInstructions) -> Self { + Self { instructions } } } @@ -38,7 +37,13 @@ impl WorldStateSection for MultiAgentUsageHintState { PreviousSectionState::Known(previous) if previous == &self.snapshot() => None, PreviousSectionState::Unknown => None, PreviousSectionState::Known(_) | PreviousSectionState::Absent => { - Some(Box::new(self.instructions.clone())) + if self.instructions.markers().0.is_empty() { + Some(Box::new(MultiAgentUsageHint::new( + &self.instructions.body(), + ))) + } else { + Some(Box::new(self.instructions.clone())) + } } } } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 9d895bbbdf..9a3164154a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1202,6 +1202,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { "{ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_ENVIRONMENT_INSTRUCTIONS" )), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), + developer_msg("ROLLED_BACK_MULTI_AGENT_ROLE"), developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( "PRETURN_CONTEXT_DIFF_CWD", diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 42bbcc2df6..178f3b3bec 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -43,6 +43,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", APPS_INSTRUCTIONS_OPEN_TAG, COLLABORATION_MODE_OPEN_TAG, + "", MULTI_AGENT_MODE_OPEN_TAG, ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG, "", diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 3938d60b4b..381ba8f81a 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1808,6 +1808,7 @@ mod tests { policy_template: Some(catalog_template.to_string()), }), permissions: None, + multi_agent: None, token_budget: None, }; @@ -1842,6 +1843,7 @@ mod tests { policy_template: None, }), permissions: None, + multi_agent: None, token_budget: None, }; @@ -1884,6 +1886,7 @@ mod tests { policy_template: Some(String::new()), }), permissions: None, + multi_agent: None, token_budget: None, }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fb12a66355..33af3adc42 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -22,6 +22,7 @@ use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; +use crate::context::MultiAgentRoleInstructions; use crate::context::NetworkRuleSaved; use crate::context::RecommendedPluginsInstructions; use crate::context::world_state::WorldState; @@ -3603,6 +3604,11 @@ impl Session { "developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => { initial_multi_agent_mode = Some(fragment); } + "developer" + if fragment.markers().0 == MultiAgentRoleInstructions::type_markers().0 => + { + separate_developer_sections.push(fragment.render()); + } "developer" if fragment.requires_separate_message() && fragment.markers().0.is_empty() => { diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index c2f139e68b..bae1969159 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,57 +1,175 @@ use crate::config::MultiAgentV2Config; +use crate::context::MultiAgentRoleInstructions; use crate::session::turn_context::TurnContext; use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -pub(super) fn usage_hint_text<'a>( - turn_context: &'a TurnContext, +const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + +At the start of your turn, you are the active agent. +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. +All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. +Child agents can also spawn their own sub-agents. +You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. + +You will receive messages in the analysis channel in the form: +``` +Message Type: MESSAGE | FINAL_ANSWER +Task name: +Sender: +Payload: + +``` +They may be addressed as to=/root +"#; +const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task. + +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. +Child agents can also spawn their own sub-agents. + +When you provide a response in the final channel, that content is immediately delivered back to your parent agent. + +You will receive messages in the analysis channel in the form: +``` +Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER +Task name: +Sender: +Payload: + +``` +You may also see them addressed as to=/root/..., which indicates your identity is /root/... +"#; +const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; +const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str = + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; +const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. + +All agents share the same directory. In detail: +- All agents have access to the same container and filesystem as you. +- All agents use the same current working directory. +- As a result, edits made by one agent are immediately visible to all other agents. +"#; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ResolvedMultiAgentV2UsageHints { + pub(crate) root: Option, + pub(crate) subagent: Option, +} + +pub(super) fn usage_hint_text( + turn_context: &TurnContext, session_source: &SessionSource, -) -> Option<&'a str> { +) -> Option { if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } - let multi_agent_v2 = &turn_context.config.multi_agent_v2; - configured_usage_hint_text_for_source(multi_agent_v2, session_source) -} - -fn configured_usage_hint_text_for_source<'a>( - multi_agent_v2: &'a MultiAgentV2Config, - session_source: &SessionSource, -) -> Option<&'a str> { + let catalog = turn_context + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.multi_agent.as_ref()) + .and_then(|messages| messages.role.as_ref()); + let snapshot = resolve_usage_hints(&turn_context.config.multi_agent_v2, catalog); match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => { - multi_agent_v2.subagent_usage_hint_text.as_deref() - } + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => snapshot.subagent, SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) - | SessionSource::Unknown => multi_agent_v2.root_agent_usage_hint_text.as_deref(), + | SessionSource::Unknown => snapshot.root, SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, } } +pub(crate) fn resolve_usage_hints( + config: &MultiAgentV2Config, + catalog: Option<&MultiAgentRoleMessages>, +) -> ResolvedMultiAgentV2UsageHints { + let resolve_role = |configured: Option<&str>, catalog: Option<&str>, bundled: &str| { + // Configured roles take precedence; empty configured or catalog roles suppress fallback. + if let Some(configured) = configured { + return (!configured.is_empty()) + .then(|| MultiAgentRoleInstructions::unmarked(configured)); + } + + let base = catalog.unwrap_or(bundled); + if base.is_empty() { + return None; + } + + let max_concurrency = config.max_concurrent_threads_per_session; + let wait_agent_guidance = if config.wait_agent_enabled { + format!("{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n") + } else { + String::new() + }; + let mut text = format!( + "{base}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_guidance}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." + ); + if config.expose_spawn_agent_model_overrides { + text.push_str("\n\n"); + text.push_str(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT); + } + + Some(if catalog.is_some() { + MultiAgentRoleInstructions::catalog(text) + } else { + MultiAgentRoleInstructions::unmarked(text) + }) + }; + + ResolvedMultiAgentV2UsageHints { + root: resolve_role( + config.root_agent_usage_hint_text.as_deref(), + catalog.and_then(|messages| messages.root.as_deref()), + DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, + ), + subagent: resolve_role( + config.subagent_usage_hint_text.as_deref(), + catalog.and_then(|messages| messages.subagent.as_deref()), + DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, + ), + } +} + pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option { if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } - // A configured hint, including an empty string, defines a custom policy instead of an - // effort-derived built-in policy. - let multi_agent_mode = match &turn_context + let catalog_mode = turn_context + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.multi_agent.as_ref()) + .and_then(|messages| messages.mode.as_ref()); + let mode_hint_text = turn_context .config .multi_agent_v2 .multi_agent_mode_hint_text - { - Some(hint_text) => MultiAgentMode::Custom(hint_text.clone()), + .as_deref() + .or_else(|| catalog_mode.and_then(|mode| mode.hint_text.as_deref())); + + // A configured or catalog hint, including an empty string, defines a custom policy instead + // of an effort-derived built-in policy. + let multi_agent_mode = match mode_hint_text { + Some(hint_text) => MultiAgentMode::Custom(hint_text.to_string()), None => match turn_context.effective_reasoning_effort() { Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive, - _ => MultiAgentMode::ExplicitRequestOnly, + _ => catalog_mode + .and_then(|messages| messages.explicit.clone()) + .map(MultiAgentMode::Custom) + .unwrap_or(MultiAgentMode::ExplicitRequestOnly), }, }; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a56c9bbdde..d6f1a0bc9b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9446,8 +9446,8 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_emp Vec::new(), |config| { let _ = config.features.enable(Feature::MultiAgentV2); - config.multi_agent_v2.root_agent_usage_hint_text = None; - config.multi_agent_v2.subagent_usage_hint_text = None; + config.multi_agent_v2.root_agent_usage_hint_text = Some(String::new()); + config.multi_agent_v2.subagent_usage_hint_text = Some(String::new()); }, ) .await; @@ -9460,7 +9460,10 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_emp matches!( message.as_slice(), ["Root guidance."] | ["Subagent guidance."] - ) + ) || message.iter().any(|text| { + text.contains("You are `/root`, the primary agent") + || text.contains("You are an agent in a team of agents") + }) }), "did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}" ); diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index d7ffb2c0bb..02b370b3d1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -138,6 +138,7 @@ async fn handle_spawn_agent( parent_turn_id: Some(turn.sub_id.clone()), root_turn_id: turn.turn_metadata_state.root_turn_id(), environments: Some(step_context.environments.to_selections()), + multi_agent_v2_usage_hints: None, }, )) .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index c8289c0fd5..b4cc6f6fd7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -5,10 +5,12 @@ use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; +use crate::session::multi_agents::resolve_usage_hints; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::tools::handlers::multi_agents_v2::message_tool::message_content; use codex_protocol::AgentPath; +use codex_protocol::protocol::MultiAgentVersion; use codex_tools::ToolSpec; #[derive(Default)] @@ -122,6 +124,29 @@ async fn handle_spawn_agent( /*trigger_turn*/ true, ); let context = AgentCommunicationContext::new(AgentCommunicationKind::Spawn, session.thread_id); + let multi_agent_v2_usage_hints = + if is_full_history_fork && turn.multi_agent_version == MultiAgentVersion::V2 { + let child_model_info = match config.model.as_deref() { + Some(model) if model != turn.model_info.slug => Some( + session + .services + .models_manager + .get_model_info(model, &config.to_models_manager_config()) + .await, + ), + _ => None, + }; + let child_catalog = child_model_info + .as_ref() + .unwrap_or(&turn.model_info) + .model_messages + .as_ref() + .and_then(|messages| messages.multi_agent.as_ref()) + .and_then(|messages| messages.role.as_ref()); + Some(resolve_usage_hints(&config.multi_agent_v2, child_catalog)) + } else { + None + }; let spawned_agent = Box::pin( session .services @@ -138,6 +163,7 @@ async fn handle_spawn_agent( parent_turn_id: Some(turn.sub_id.clone()), root_turn_id: turn.turn_metadata_state.root_turn_id(), environments: Some(step_context.environments.to_selections()), + multi_agent_v2_usage_hints, }, ), ) diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 643ff5d91f..15069ea09e 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -41,6 +41,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul workspace_write: None, read_only: Some("remote catalog permissions: {{ network_access }}".to_string()), }), + multi_agent: None, token_budget: None, }); let models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 7a6193a112..a3a3824b0c 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -70,6 +70,7 @@ fn model_with_collaboration_messages( collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); model_messages.collaboration_modes = Some(CollaborationModeMessages { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index e8a6940f84..caf6c3c27c 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -510,6 +510,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index 3dae9bbee3..325be29e11 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -3,6 +3,9 @@ use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_features::Feature; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::MultiAgentMessages; +use codex_protocol::openai_models::MultiAgentModeMessages; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::EventMsg; @@ -21,12 +24,44 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use test_case::test_case; const NO_SPAWN_TEXT: &str = "Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; const CUSTOM_MODE_HINT_TEXT: &str = "Use the configured delegation policy."; +const CATALOG_MODE_HINT_TEXT: &str = "Use the model catalog delegation policy."; +const CATALOG_EXPLICIT_TEXT: &str = "Use explicit delegation from the model catalog."; +const SECOND_MODEL_EXPLICIT_TEXT: &str = "Second model explicit mode."; +const FIRST_MODEL_ROOT_ROLE_TEXT: &str = "First model root role."; +const SECOND_MODEL_ROOT_ROLE_TEXT: &str = "Second model root role."; const ROOT_USAGE_HINT_TEXT: &str = "Root usage hint."; +#[derive(Clone, Copy, Debug)] +enum ModeHintSource { + ConfiguredHint, + CatalogHint, +} + +fn set_multi_agent_mode( + model_info: &mut ModelInfo, + explicit: &str, + hint_text: Option<&str>, + root_role: Option<&str>, +) { + if let Some(model_messages) = model_info.model_messages.as_mut() { + model_messages.multi_agent = Some(MultiAgentMessages { + role: root_role.map(|root| MultiAgentRoleMessages { + root: Some(root.to_string()), + subagent: None, + }), + mode: Some(MultiAgentModeMessages { + explicit: Some(explicit.to_string()), + hint_text: hint_text.map(str::to_string), + }), + }); + } +} + fn add_ultra_reasoning(model_info: &mut ModelInfo) { model_info .supported_reasoning_levels @@ -125,8 +160,10 @@ async fn ultra_reasoning_uses_max_and_proactive_mode() -> Result<()> { Ok(()) } +#[test_case(ModeHintSource::ConfiguredHint; "configured hint overrides catalog")] +#[test_case(ModeHintSource::CatalogHint; "catalog hint overrides reasoning effort")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn configured_mode_hint_uses_custom_mode_across_reasoning_efforts() -> Result<()> { +async fn mode_hints_override_reasoning_effort(source: ModeHintSource) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -143,8 +180,19 @@ async fn configured_mode_hint_uses_custom_mode_across_reasoning_efforts() -> Res ) .await; let test = test_codex() - .with_model_info_override("gpt-5.4", add_ultra_reasoning) - .with_config(configure_custom_mode_hint) + .with_model_info_override("gpt-5.4", |model_info| { + add_ultra_reasoning(model_info); + set_multi_agent_mode( + model_info, + CATALOG_EXPLICIT_TEXT, + Some(CATALOG_MODE_HINT_TEXT), + /*root_role*/ None, + ); + }) + .with_config(move |config| match source { + ModeHintSource::ConfiguredHint => configure_custom_mode_hint(config), + ModeHintSource::CatalogHint => configure_multi_agent_v2(config), + }) .build(&server) .await?; submit_turn(&test.codex, "explicit", Some(ReasoningEffort::High)).await?; @@ -155,15 +203,116 @@ async fn configured_mode_hint_uses_custom_mode_across_reasoning_efforts() -> Res let first_texts = developer_texts(&first_input); let second_input = requests[1].input(); let second_texts = developer_texts(&second_input); - let instruction_counts = |texts: &[&str]| { - ( - count_containing(texts, CUSTOM_MODE_HINT_TEXT), - count_containing(texts, NO_SPAWN_TEXT), - count_containing(texts, PROACTIVE_TEXT), - ) + let (expected_hint, suppressed_hint) = match source { + ModeHintSource::ConfiguredHint => (CUSTOM_MODE_HINT_TEXT, CATALOG_MODE_HINT_TEXT), + ModeHintSource::CatalogHint => (CATALOG_MODE_HINT_TEXT, CATALOG_EXPLICIT_TEXT), }; - assert_eq!(instruction_counts(&first_texts), (1, 0, 0)); - assert_eq!(instruction_counts(&second_texts), (1, 0, 0)); + for texts in [&first_texts, &second_texts] { + assert_eq!( + ( + count_containing(texts, expected_hint), + count_containing(texts, NO_SPAWN_TEXT), + count_containing(texts, PROACTIVE_TEXT), + ), + (1, 0, 0) + ); + assert_eq!(count_containing(texts, suppressed_hint), 0); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let test = test_codex() + .with_model_info_override("gpt-5.2", |model_info| { + set_multi_agent_mode( + model_info, + SECOND_MODEL_EXPLICIT_TEXT, + /*hint_text*/ None, + Some(SECOND_MODEL_ROOT_ROLE_TEXT), + ); + }) + .with_model_info_override("gpt-5.4", |model_info| { + set_multi_agent_mode( + model_info, + CATALOG_EXPLICIT_TEXT, + /*hint_text*/ None, + Some(FIRST_MODEL_ROOT_ROLE_TEXT), + ); + }) + .with_config(configure_multi_agent_v2) + .build_with_auto_env(&server) + .await?; + submit_turn(&test.codex, "first model", Some(ReasoningEffort::High)).await?; + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some("gpt-5.2".to_string()), + ..Default::default() + }, + ) + .await?; + submit_turn(&test.codex, "second model", Some(ReasoningEffort::High)).await?; + + let requests = responses.requests(); + for (index, request) in requests.iter().enumerate() { + let input = request.input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, CATALOG_EXPLICIT_TEXT), + count_containing(&texts, SECOND_MODEL_EXPLICIT_TEXT), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (1, usize::from(index == 1), 0, 0) + ); + } + + let developer_messages = requests[1].message_input_text_groups("developer"); + let [first_role_position, second_role_position] = + [FIRST_MODEL_ROOT_ROLE_TEXT, SECOND_MODEL_ROOT_ROLE_TEXT].map(|role| { + developer_messages + .iter() + .position(|group| { + matches!( + group.as_slice(), + [text] if text.starts_with("") + && text.ends_with("") + && text.contains(role) + ) + }) + .expect("catalog root role should be a standalone marked developer message") + }); + let active_mode_position = developer_messages + .iter() + .rposition(|group| { + group + .iter() + .any(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG)) + }) + .expect("active multi-agent mode should be present"); + assert!( + first_role_position < second_role_position && second_role_position < active_mode_position, + "catalog root roles should remain ordered before the active mode: {developer_messages:?}" + ); + Ok(()) } diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 306b970ee1..8891a56d79 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -58,6 +58,7 @@ fn model_with_approval_messages( collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); model @@ -75,6 +76,7 @@ fn model_with_permission_messages( collaboration_modes: None, auto_review: None, permissions: Some(permissions), + multi_agent: None, token_budget: None, }); model @@ -227,6 +229,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); let mut builder = test_codex() diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 25b9b82005..eb508e98dd 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -643,6 +643,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), include_skills_usage_instructions: false, @@ -767,6 +768,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 74b7399bd2..04129d86ee 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -110,6 +110,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), ..specific @@ -123,6 +124,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), ..generic @@ -770,6 +772,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 83905d029b..34e7ff0ea0 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -3,6 +3,7 @@ use anyhow::Result; use codex_core::config::AgentRoleConfig; +use codex_core::config::Config; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; @@ -20,6 +21,7 @@ use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; @@ -52,6 +54,22 @@ fn spawn_agent_exposes_agent_type(body: &Value, namespace: &str) -> bool { .is_some() } +fn resolved_root_usage_hint(config: &Config, request: &ResponsesRequest) -> String { + if let Some(configured) = config.multi_agent_v2.root_agent_usage_hint_text.as_deref() { + return configured.to_string(); + } + + request + .message_input_text_groups("developer") + .into_iter() + .rev() + .find_map(|group| { + (group.len() == 1 && group[0].contains("available concurrency slots")) + .then(|| group[0].clone()) + }) + .expect("resolved root usage hint should be a standalone developer message") +} + fn test_model_info( slug: &str, display_name: &str, @@ -483,18 +501,13 @@ async fn multi_agent_v2_cold_resume_refreshes_legacy_usage_hints_once( } }); let resumed = resumed_builder.resume(&server, home, rollout_path).await?; - let current_usage_hint = resumed - .config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone() - .expect("current root usage hint should be configured"); resumed.submit_turn("first turn after upgrading").await?; resumed.submit_turn("second turn after upgrading").await?; let requests = resumed_responses.requests(); assert_eq!(requests.len(), 2); + let current_usage_hint = resolved_root_usage_hint(&resumed.config, &requests[0]); for request in &requests { let developer_messages = request.message_input_text_groups("developer"); let current_usage_hint_positions = developer_messages @@ -624,12 +637,6 @@ async fn multi_agent_v2_resume_refreshes_changed_wait_guidance( ) .await; let resumed = test_codex().resume(&server, home, rollout_path).await?; - let current_usage_hint = resumed - .config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone() - .expect("current root usage hint should be configured"); resumed .submit_turn("first turn with updated wait-agent availability") @@ -640,6 +647,7 @@ async fn multi_agent_v2_resume_refreshes_changed_wait_guidance( let requests = resumed_responses.requests(); assert_eq!(requests.len(), 2); + let current_usage_hint = resolved_root_usage_hint(&resumed.config, &requests[0]); for request in &requests { let developer_messages = request.message_input_text_groups("developer"); let current_usage_hint_positions = developer_messages diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 4f6490f60e..260618a2bc 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -10,6 +10,8 @@ use codex_models_manager::bundled_models_response; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::MultiAgentMessages; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; @@ -1141,6 +1143,26 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + let model_catalog = config.model_catalog.get_or_insert_with(|| { + bundled_models_response().expect("bundled models.json should parse") + }); + for model in [INHERITED_MODEL, V2_DEFAULT_MODEL, V2_REQUESTED_MODEL] { + let model_info = model_catalog + .models + .iter_mut() + .find(|model_info| model_info.slug == model) + .unwrap_or_else(|| panic!("{model} should exist in bundled models.json")); + let multi_agent = model_info + .model_messages + .as_mut() + .expect("bundled model should include model messages") + .multi_agent + .get_or_insert_with(MultiAgentMessages::default); + multi_agent.role = Some(MultiAgentRoleMessages { + root: Some(format!("{model} root role.")), + subagent: Some(format!("{model} subagent role.")), + }); + } if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) { config .features @@ -1182,6 +1204,18 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co let child_request = wait_for_request_with_model(&child_request_log, expected_model).await?; assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT)); + let child_developer_messages = child_request.message_input_texts("developer"); + assert_eq!( + child_developer_messages + .iter() + .filter(|message| message.contains(&format!("{expected_model} subagent role."))) + .count(), + 1 + ); + assert!(!child_developer_messages.iter().any(|message| { + message.contains(&format!("{INHERITED_MODEL} root role.")) + || message.contains(&format!("{INHERITED_MODEL} subagent role.")) + })); if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) { let reminder_count = |request: &ResponsesRequest| { request diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index ba07c61606..cdbf7e7e72 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -57,6 +57,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); model_messages.instructions_template = Some(base_instructions.clone()); @@ -198,6 +199,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }, _ => ModelMessages { @@ -207,6 +209,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }, } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index bc949d9442..0da6200ad4 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -5,6 +5,9 @@ use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::CollaborationModeMessages; use codex_protocol::openai_models::ModelTokenBudgetConfig; +use codex_protocol::openai_models::MultiAgentMessages; +use codex_protocol::openai_models::MultiAgentModeMessages; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::PermissionMessages; use pretty_assertions::assert_eq; @@ -39,6 +42,16 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { workspace_write: Some(String::new()), read_only: None, }; + let multi_agent = MultiAgentMessages { + role: Some(MultiAgentRoleMessages { + root: Some("root base".to_string()), + subagent: Some("subagent base".to_string()), + }), + mode: Some(MultiAgentModeMessages { + explicit: Some("explicit mode".to_string()), + hint_text: Some("mode hint".to_string()), + }), + }; let token_budget = ModelTokenBudgetConfig { reminder_threshold_tokens: 128, reminder_message_template: "budget reminder".to_string(), @@ -57,6 +70,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { collaboration_modes: Some(collaboration_modes.clone()), auto_review: Some(auto_review.clone()), permissions: Some(permissions.clone()), + multi_agent: Some(multi_agent.clone()), token_budget: Some(token_budget.clone()), }); let config = ModelsManagerConfig { @@ -75,6 +89,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { collaboration_modes: Some(collaboration_modes), auto_review: Some(auto_review), permissions: Some(permissions), + multi_agent: Some(multi_agent), token_budget: Some(token_budget), }) ); @@ -104,6 +119,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); let config = ModelsManagerConfig { @@ -122,6 +138,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }) ); @@ -147,6 +164,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }), "unexpected model messages for {slug}" @@ -186,6 +204,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index fba05c6e37..70672cde94 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -531,6 +531,7 @@ pub struct ModelMessages { pub collaboration_modes: Option, pub auto_review: Option, pub permissions: Option, + pub multi_agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub token_budget: Option, } @@ -572,6 +573,24 @@ pub struct PermissionMessages { pub read_only: Option, } +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct MultiAgentMessages { + pub role: Option, + pub mode: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct MultiAgentRoleMessages { + pub root: Option, + pub subagent: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct MultiAgentModeMessages { + pub explicit: Option, + pub hint_text: Option, +} + impl ModelMessages { fn has_personality_placeholder(&self) -> bool { self.instructions_template @@ -737,6 +756,7 @@ where collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }); messages.instructions_template = Some(base_instructions); @@ -925,6 +945,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, } ); @@ -1018,6 +1039,28 @@ mod tests { ); } + #[test] + fn multi_agent_messages_preserve_missing_and_empty_values() { + let messages: ModelMessages = from_str( + r#"{"instructions_template":null,"instructions_variables":null,"multi_agent":{"role":{"root":"","subagent":"subagent base"},"mode":{"explicit":"explicit mode","hint_text":""}}}"#, + ) + .expect("multi-agent messages should deserialize"); + + assert_eq!( + messages.multi_agent, + Some(MultiAgentMessages { + role: Some(MultiAgentRoleMessages { + root: Some(String::new()), + subagent: Some("subagent base".to_string()), + }), + mode: Some(MultiAgentModeMessages { + explicit: Some("explicit mode".to_string()), + hint_text: Some(String::new()), + }), + }) + ); + } + #[test] fn collaboration_mode_messages_preserve_missing_and_empty_values() { let messages: ModelMessages = from_str( @@ -1043,6 +1086,7 @@ mod tests { }), auto_review: None, permissions: None, + multi_agent: None, token_budget: None, } ); @@ -1123,6 +1167,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, })); @@ -1144,6 +1189,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, })); assert_eq!( @@ -1166,6 +1212,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, })); assert_eq!( @@ -1199,6 +1246,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, })); @@ -1238,6 +1286,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }) ); @@ -1288,6 +1337,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }))], }; @@ -1324,6 +1374,7 @@ mod tests { workspace_write: Some("workspace".to_string()), read_only: None, }), + multi_agent: None, token_budget: None, }; let mut value = serde_json::to_value(ModelsResponse { @@ -1345,6 +1396,7 @@ mod tests { collaboration_modes: None, auto_review: None, permissions: None, + multi_agent: None, token_budget: None, }; let mut value = serde_json::to_value(ModelsResponse {