From cc60cafc50324014dcc5af6ee9f149d2c9672e55 Mon Sep 17 00:00:00 2001 From: Friel Date: Sun, 29 Mar 2026 17:56:12 +0000 Subject: [PATCH] feat(core): add role-level fork_context defaults --- codex-rs/core/config.schema.json | 4 ++++ codex-rs/core/src/agent/control_tests.rs | 1 + codex-rs/core/src/agent/role.rs | 10 ++++++++++ codex-rs/core/src/agent/role_tests.rs | 16 ++++++++++++++++ codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/config/agent_roles.rs | 3 +++ codex-rs/core/src/config/config_tests.rs | 5 +++++ codex-rs/core/src/config/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents/spawn.rs | 11 +++++++---- .../src/tools/handlers/multi_agents_v2/spawn.rs | 11 +++++++---- .../core/tests/suite/subagent_notifications.rs | 2 ++ 11 files changed, 61 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e36f495a1b..19171283bf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -21,6 +21,10 @@ "description": "Human-facing role documentation used in spawn tool guidance. Required unless supplied by the referenced agent role file.", "type": "string" }, + "fork_context": { + "description": "Default fork-context behavior for this role.", + "type": "boolean" + }, "nickname_candidates": { "description": "Candidate nicknames for agents spawned with this role.", "items": { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2def29af9a..1db537531a 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1373,6 +1373,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Atlas".to_string()]), + fork_context: None, }, ); let (parent_thread_id, _parent_thread) = harness.start_thread().await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index b7d7b55ab1..cb738de15a 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -119,6 +119,13 @@ pub(crate) fn resolve_role_config<'a>( .or_else(|| built_in::configs().get(role_name)) } +pub(crate) fn default_fork_context_for_role(config: &Config, role_name: Option<&str>) -> bool { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + resolve_role_config(config, role_name) + .and_then(|role| role.fork_context) + .unwrap_or(false) +} + fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { let role_selects_provider = role_layer_toml.get("model_provider").is_some(); let role_selects_profile = role_layer_toml.get("profile").is_some(); @@ -352,6 +359,7 @@ mod built_in { description: Some("Default agent.".to_string()), config_file: None, nickname_candidates: None, + fork_context: Some(true), } ), ( @@ -366,6 +374,7 @@ Rules: - Reuse existing explorers for related questions."#.to_string()), config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), nickname_candidates: None, + fork_context: Some(true), } ), ( @@ -381,6 +390,7 @@ Rules: - Always tell workers they are **not alone in the codebase**, and they should not revert the edits made by others, and they should adjust their implementation to accommodate the changes made by others. This is important because there may be multiple workers making changes in parallel, and they need to be aware of each other's work to avoid conflicts and ensure a cohesive final product."#.to_string()), config_file: None, nickname_candidates: None, + fork_context: Some(true), } ), // Awaiter is temp removed diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 5b3941ebda..a25aa9750c 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -96,6 +96,7 @@ async fn apply_role_returns_unavailable_for_missing_user_role_file() { description: None, config_file: Some(PathBuf::from("/path/does/not/exist.toml")), nickname_candidates: None, + fork_context: None, }, ); @@ -116,6 +117,7 @@ async fn apply_role_returns_unavailable_for_invalid_user_role_toml() { description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -147,6 +149,7 @@ model = "role-model" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -178,6 +181,7 @@ async fn apply_role_preserves_unspecified_keys() { description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -237,6 +241,7 @@ model_provider = "test-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -291,6 +296,7 @@ model_verbosity = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -357,6 +363,7 @@ model_provider = "role-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -415,6 +422,7 @@ model_provider = "base-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -479,6 +487,7 @@ model_reasoning_effort = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -523,6 +532,7 @@ writable_roots = ["./sandbox-root"] description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -585,6 +595,7 @@ async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -628,6 +639,7 @@ enabled = false description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -662,6 +674,7 @@ fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() { description: Some("user override".to_string()), config_file: None, nickname_candidates: None, + fork_context: None, }, ), ("researcher".to_string(), AgentRoleConfig::default()), @@ -683,6 +696,7 @@ fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() { description: Some("first".to_string()), config_file: None, nickname_candidates: None, + fork_context: None, }, )]); @@ -710,6 +724,7 @@ fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() { description: Some("Research carefully.".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, )]); @@ -735,6 +750,7 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { description: Some("Review carefully.".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, )]); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f1a6652770..15fdfd3bc8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2369,6 +2369,7 @@ enabled = false description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); crate::agent::role::apply_role_to_config(&mut child_config, Some("custom")) diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index c527435e92..1b5bc03864 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -157,6 +157,7 @@ fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleCon .nickname_candidates .clone() .or(fallback.nickname_candidates.clone()); + role.fork_context = role.fork_context.or(fallback.fork_context); } fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result> { @@ -190,6 +191,7 @@ fn agent_role_config_from_toml( description, config_file, nickname_candidates, + fork_context: role.fork_context, }) } @@ -480,6 +482,7 @@ fn discover_agent_roles_in_dir( description: parsed_file.description, config_file: Some(agent_file), nickname_candidates: parsed_file.nickname_candidates, + fork_context: None, }, ); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 00227fe2b3..327d931591 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3197,6 +3197,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { description: Some("Research role".to_string()), config_file: Some(missing_path.abs()), nickname_candidates: None, + fork_context: None, }, )]), }), @@ -4065,6 +4066,7 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<() " Hypatia ".to_string(), "Noether".to_string(), ]), + fork_context: None, }, )]), }), @@ -4103,6 +4105,7 @@ fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(Vec::new()), + fork_context: None, }, )]), }), @@ -4138,6 +4141,7 @@ fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Re description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]), + fork_context: None, }, )]), }), @@ -4173,6 +4177,7 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Agent ".to_string()]), + fork_context: None, }, )]), }), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1a0722119b..43a95d2262 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1571,6 +1571,8 @@ pub struct AgentRoleConfig { pub config_file: Option, /// Candidate nicknames for agents spawned with this role. pub nickname_candidates: Option>, + /// Default fork-context behavior for this role. + pub fork_context: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -1586,6 +1588,9 @@ pub struct AgentRoleToml { /// Candidate nicknames for agents spawned with this role. pub nickname_candidates: Option>, + + /// Default fork-context behavior for this role. + pub fork_context: Option, } impl From for Tools { 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 a29d4d172a..c03ff87f3e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -3,6 +3,7 @@ use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; +use crate::agent::role::default_fork_context_for_role; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; @@ -61,7 +62,10 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if !args.fork_context { + let fork_context = args + .fork_context + .unwrap_or_else(|| default_fork_context_for_role(&turn.config, role_name)); + if !fork_context { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), @@ -91,7 +95,7 @@ impl ToolHandler for Handler { /*task_name*/ None, )?), SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_parent_spawn_call_id: fork_context.then(|| call_id.clone()), }, ) .await @@ -176,8 +180,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, - #[serde(default)] - fork_context: bool, + fork_context: Option, } #[derive(Debug, Serialize)] 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 e88e734aec..2ab15475cd 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 @@ -4,6 +4,7 @@ use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; +use crate::agent::role::default_fork_context_for_role; use codex_protocol::AgentPath; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; @@ -64,7 +65,10 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if !args.fork_context { + let fork_context = args + .fork_context + .unwrap_or_else(|| default_fork_context_for_role(&turn.config, role_name)); + if !fork_context { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), @@ -114,7 +118,7 @@ impl ToolHandler for Handler { }, Some(spawn_source), SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_parent_spawn_call_id: fork_context.then(|| call_id.clone()), }, ) .await @@ -206,8 +210,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, - #[serde(default)] - fork_context: bool, + fork_context: Option, } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a1a8c29b9c..694637405a 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -467,6 +467,7 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> description: Some("Custom role".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); }) @@ -515,6 +516,7 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< description: Some("Custom role".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); });