From 6372abdbf070be66b35a25e95a2f32761506c996 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/config/src/config_toml.rs | 3 +++ 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 | 2 ++ .../src/tools/handlers/multi_agents/spawn.rs | 16 ++++++++++------ .../src/tools/handlers/multi_agents_v2/spawn.rs | 11 ++++++++--- .../core/tests/suite/subagent_notifications.rs | 2 ++ 12 files changed, 65 insertions(+), 9 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index caf9d25b88..fa348c87f5 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -550,6 +550,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/config.schema.json b/codex-rs/core/config.schema.json index e4e7ff85b7..fc04fd1d4b 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 4a5b675f5c..98548c1f3e 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1451,6 +1451,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 83c2b1843b..24766a9ac6 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -126,6 +126,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(); @@ -359,6 +366,7 @@ mod built_in { description: Some("Default agent.".to_string()), config_file: None, nickname_candidates: None, + fork_context: Some(true), } ), ( @@ -373,6 +381,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), } ), ( @@ -388,6 +397,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 d68376ddd6..198709b580 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -112,6 +112,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, }, ); @@ -132,6 +133,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, }, ); @@ -163,6 +165,7 @@ model = "role-model" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -194,6 +197,7 @@ async fn apply_role_preserves_unspecified_keys() { description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -253,6 +257,7 @@ model_provider = "test-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -307,6 +312,7 @@ model_verbosity = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -373,6 +379,7 @@ model_provider = "role-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -431,6 +438,7 @@ model_provider = "base-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -495,6 +503,7 @@ model_reasoning_effort = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -539,6 +548,7 @@ writable_roots = ["./sandbox-root"] description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -601,6 +611,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, }, ); @@ -644,6 +655,7 @@ enabled = false description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -678,6 +690,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()), @@ -699,6 +712,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, }, )]); @@ -726,6 +740,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, }, )]); @@ -751,6 +766,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 f46a1f4a72..904c3ba0bc 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2438,6 +2438,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 24d26ebf47..05730b9d08 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 deba8a47b3..4553c07880 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3255,6 +3255,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, }, )]), }), @@ -4123,6 +4124,7 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<() " Hypatia ".to_string(), "Noether".to_string(), ]), + fork_context: None, }, )]), }), @@ -4161,6 +4163,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, }, )]), }), @@ -4196,6 +4199,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, }, )]), }), @@ -4231,6 +4235,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 e0a24bcc10..87180cbc40 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1108,6 +1108,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, } fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { 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 4bef4af391..29ba771a61 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -4,6 +4,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,11 @@ 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(), @@ -75,7 +80,7 @@ impl ToolHandler for Handler { apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; - if args.fork_context { + if fork_context { restore_forked_spawn_agent_model_config(&mut config, turn.as_ref()); } apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; @@ -95,8 +100,8 @@ impl ToolHandler for Handler { /*task_name*/ None, )?), SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + fork_parent_spawn_call_id: fork_context.then(|| call_id.clone()), + fork_mode: fork_context.then_some(SpawnAgentForkMode::FullHistory), }, ) .await @@ -181,8 +186,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 643c972463..c1a2a34357 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 @@ -6,6 +6,7 @@ use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; use codex_features::Feature; +use crate::agent::role::default_fork_context_for_role; use codex_protocol::AgentPath; use codex_protocol::models::DeveloperInstructions; use codex_protocol::protocol::InterAgentCommunication; @@ -38,12 +39,13 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; let role_name = args .agent_type .as_deref() .map(str::trim) .filter(|role| !role.is_empty()); + let fork_context = default_fork_context_for_role(&turn.config, role_name); + let fork_mode = args.fork_mode(fork_context)?; let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; let prompt = render_input_preview(&initial_operation); @@ -241,7 +243,10 @@ struct SpawnAgentArgs { } impl SpawnAgentArgs { - fn fork_mode(&self) -> Result, FunctionCallError> { + fn fork_mode( + &self, + default_fork_context: bool, + ) -> Result, FunctionCallError> { if self.fork_context.is_some() { return Err(FunctionCallError::RespondToModel( "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(), @@ -254,7 +259,7 @@ impl SpawnAgentArgs { .map(str::trim) .filter(|fork_turns| !fork_turns.is_empty()) else { - return Ok(None); + return Ok(default_fork_context.then_some(SpawnAgentForkMode::FullHistory)); }; if fork_turns.eq_ignore_ascii_case("none") { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 1f7dea6fdf..eb59f1c6ad 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -536,6 +536,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, }, ); }) @@ -584,6 +585,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, }, ); });