From 3f338e4a6a70413dea864441f06f72becd49615c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 17:21:42 +0100 Subject: [PATCH 1/8] feat: explorer collab (#9918) --- codex-rs/core/src/agent/role.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index acb2481fc8..dd05c18470 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,20 +1,22 @@ use crate::config::Config; use crate::protocol::SandboxPolicy; +use codex_protocol::openai_models::ReasoningEffort; use serde::Deserialize; use serde::Serialize; /// Base instructions for the orchestrator role. const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md"); -/// Base instructions for the worker role. -const WORKER_PROMPT: &str = include_str!("../../gpt-5.2-codex_prompt.md"); -/// Default worker model override used by the worker role. -const WORKER_MODEL: &str = "gpt-5.2-codex"; +/// Default model override used. +// TODO(jif) update when we have something smarter. +const EXPLORER_MODEL: &str = "gpt-5.2-codex"; /// Enumerated list of all supported agent roles. const ALL_ROLES: [AgentRole; 3] = [ AgentRole::Default, - AgentRole::Orchestrator, + AgentRole::Explorer, AgentRole::Worker, + // TODO(jif) add when we have stable prompts + models + // AgentRole::Orchestrator, ]; /// Hard-coded agent role selection used when spawning sub-agents. @@ -27,6 +29,8 @@ pub enum AgentRole { Orchestrator, /// Task-executing agent with a fixed model override. Worker, + /// Task-executing agent with a fixed model override. + Explorer, } /// Immutable profile data that drives per-agent configuration overrides. @@ -36,6 +40,8 @@ pub struct AgentProfile { pub base_instructions: Option<&'static str>, /// Optional model override. pub model: Option<&'static str>, + /// Optional reasoning effort override. + pub reasoning_effort: Option, /// Whether to force a read-only sandbox policy. pub read_only: bool, } @@ -58,8 +64,13 @@ impl AgentRole { ..Default::default() }, AgentRole::Worker => AgentProfile { - base_instructions: Some(WORKER_PROMPT), - model: Some(WORKER_MODEL), + // base_instructions: Some(WORKER_PROMPT), + // model: Some(WORKER_MODEL), + ..Default::default() + }, + AgentRole::Explorer => AgentProfile { + model: Some(EXPLORER_MODEL), + reasoning_effort: Some(ReasoningEffort::Low), ..Default::default() }, } @@ -74,6 +85,9 @@ impl AgentRole { if let Some(model) = profile.model { config.model = Some(model.to_string()); } + if let Some(reasoning_effort) = profile.reasoning_effort { + config.model_reasoning_effort = Some(reasoning_effort) + } if profile.read_only { config .sandbox_policy From 70d59593988d3af390527a6aadd6e801da5d6a33 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 18:05:36 +0100 Subject: [PATCH 2/8] feat: disable collab at max depth (#9899) --- codex-rs/core/src/codex.rs | 11 ++++++- codex-rs/core/src/tools/handlers/collab.rs | 34 +++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8a66fca0e1..6401605eec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,6 +12,7 @@ use crate::CodexAuth; use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; +use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::agent::agent_status_from_event; use crate::compact; use crate::compact::run_inline_auto_compact_task; @@ -52,6 +53,7 @@ use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; @@ -240,7 +242,7 @@ impl Codex { /// Spawn a new [`Codex`] and initialize the session. #[allow(clippy::too_many_arguments)] pub(crate) async fn spawn( - config: Config, + mut config: Config, auth_manager: Arc, models_manager: Arc, skills_manager: Arc, @@ -262,6 +264,12 @@ impl Codex { ); } + if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source + && depth >= MAX_THREAD_SPAWN_DEPTH + { + config.features.disable(Feature::Collab); + } + let enabled_skills = loaded_skills.enabled_skills(); let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await; @@ -3587,6 +3595,7 @@ mod tests { use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; + use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputPayload; use crate::protocol::CompactedItem; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 83ee53905b..00190648f0 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -1,8 +1,10 @@ use crate::agent::AgentStatus; +use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::error::CodexErr; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -78,7 +80,7 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; use crate::agent::AgentRole; - use crate::agent::MAX_THREAD_SPAWN_DEPTH; + use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; use codex_protocol::protocol::SessionSource; @@ -113,9 +115,9 @@ mod spawn { let session_source = turn.client.get_session_source(); let child_depth = next_thread_spawn_depth(&session_source); if exceeds_thread_spawn_depth_limit(child_depth) { - return Err(FunctionCallError::RespondToModel(format!( - "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" - ))); + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } session .send_event( @@ -128,8 +130,11 @@ mod spawn { .into(), ) .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + let mut config = build_agent_spawn_config( + &session.get_base_instructions().await, + turn.as_ref(), + child_depth, + )?; agent_role .apply_to_config(&mut config) .map_err(FunctionCallError::RespondToModel)?; @@ -582,6 +587,7 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { fn build_agent_spawn_config( base_instructions: &BaseInstructions, turn: &TurnContext, + child_depth: i32, ) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); @@ -607,6 +613,12 @@ fn build_agent_spawn_config( .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; + + // If the new agent will be at max depth: + if exceeds_thread_spawn_depth_limit(child_depth + 1) { + config.features.disable(Feature::Collab); + } + Ok(config) } @@ -778,9 +790,9 @@ mod tests { }; assert_eq!( err, - FunctionCallError::RespondToModel(format!( - "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" - )) + FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string() + ) ); } @@ -1144,7 +1156,7 @@ mod tests { turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.client.get_model()); @@ -1189,7 +1201,7 @@ mod tests { text: "base".to_string(), }; - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config"); assert_eq!(config.user_instructions, base_config.user_instructions); } From 6316e57497d049abb25b9b5318e7219e95c30ff4 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 26 Jan 2026 17:49:31 +0000 Subject: [PATCH 3/8] Fix up config disabled err msg (#9916) **Before:** image **After:** image Two changes: 1. only display if there is a `config.toml` that is skipped (i.e. if there is just `.codex/skills` but no `.codex/config.toml` we do not display the error) 2. clarify the implications and the fix in the error message. --- codex-rs/app-server/src/lib.rs | 8 +++++-- codex-rs/core/src/config_loader/mod.rs | 32 ++++++++++++-------------- codex-rs/tui/src/app.rs | 8 +++++-- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c6d943ae46..b92e182668 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -134,7 +134,7 @@ fn project_config_warning(config: &Config) -> Option .disabled_reason .as_ref() .map(ToString::to_string) - .unwrap_or_else(|| "Config folder disabled.".to_string()), + .unwrap_or_else(|| "config.toml is disabled.".to_string()), )); } } @@ -143,7 +143,11 @@ fn project_config_warning(config: &Config) -> Option return None; } - let mut message = "The following config folders are disabled:\n".to_string(); + let mut message = concat!( + "Project config.toml files are disabled in the following folders. ", + "Settings in those files are ignored, but skills and exec policies still load.\n", + ) + .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { let display_index = index + 1; message.push_str(&format!(" {display_index}. {folder}\n")); diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 6d85a79360..ae56c384e4 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -512,10 +512,10 @@ impl ProjectTrustContext { let user_config_file = self.user_config_file.as_path().display(); match decision.trust_level { Some(TrustLevel::Untrusted) => Some(format!( - "{trust_key} is marked as untrusted in {user_config_file}. Mark it trusted to enable project config folders." + "{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted." )), _ => Some(format!( - "Add {trust_key} as a trusted project in {user_config_file}." + "To load config.toml, add {trust_key} as a trusted project in {user_config_file}." )), } } @@ -526,21 +526,16 @@ fn project_layer_entry( dot_codex_folder: &AbsolutePathBuf, layer_dir: &AbsolutePathBuf, config: TomlValue, + config_toml_exists: bool, ) -> ConfigLayerEntry { - match trust_context.disabled_reason_for_dir(layer_dir) { - Some(reason) => ConfigLayerEntry::new_disabled( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_folder.clone(), - }, - config, - reason, - ), - None => ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_folder.clone(), - }, - config, - ), + let source = ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }; + + if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) { + ConfigLayerEntry::new_disabled(source, config, reason) + } else { + ConfigLayerEntry::new(source, config) } } @@ -715,13 +710,15 @@ async fn load_project_layers( &dot_codex_abs, &layer_dir, TomlValue::Table(toml::map::Map::new()), + true, )); continue; } }; let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - let entry = project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config); + let entry = + project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config, true); layers.push(entry); } Err(err) => { @@ -734,6 +731,7 @@ async fn load_project_layers( &dot_codex_abs, &layer_dir, TomlValue::Table(toml::map::Map::new()), + false, )); } else { let config_file_display = config_file.as_path().display(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d3185adf34..bc006a2928 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -201,7 +201,7 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) .disabled_reason .as_ref() .map(ToString::to_string) - .unwrap_or_else(|| "Config folder disabled.".to_string()), + .unwrap_or_else(|| "config.toml is disabled.".to_string()), )); } @@ -209,7 +209,11 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) return; } - let mut message = "The following config folders are disabled:\n".to_string(); + let mut message = concat!( + "Project config.toml files are disabled in the following folders. ", + "Settings in those files are ignored, but skills and exec policies still load.\n", + ) + .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { let display_index = index + 1; message.push_str(&format!(" {display_index}. {folder}\n")); From 3ba702c5b6eb53523d3ff644789e900fffa3533c Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 26 Jan 2026 09:52:38 -0800 Subject: [PATCH 4/8] Feat: add isOther to question returned by request user input tool (#9890) ### Summary Add `isOther` to question object from request_user_input tool input and remove `other` option from the tool prompt to better handle tool input. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 1 + codex-rs/core/src/tools/spec.rs | 12 +++++++++++- codex-rs/core/tests/suite/request_user_input.rs | 2 ++ codex-rs/docs/protocol_v1.md | 2 +- codex-rs/protocol/src/request_user_input.rs | 4 ++++ .../tui/src/bottom_pane/request_user_input/mod.rs | 3 +++ 8 files changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 65648bbdb0..28954c7cde 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2420,6 +2420,8 @@ pub struct ToolRequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + #[serde(default)] + pub is_other: bool, pub options: Option>, } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0cb6fb1bc6..20e7db762b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -278,6 +278,7 @@ pub(crate) async fn apply_bespoke_event_handling( id: question.id, header: question.header, question: question.question, + is_other: question.is_other, options: question.options.map(|options| { options .into_iter() diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index e15319e02f..2a5b5d2159 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -67,6 +67,7 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result ToolSpec { let options_schema = JsonSchema::Array { description: Some( - "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option." + "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; use isOther on the question to request a free form choice. If the question is free form in nature, please do not have any option." .to_string(), ), items: Box::new(JsonSchema::Object { @@ -586,6 +586,15 @@ fn create_request_user_input_tool() -> ToolSpec { description: Some("Single-sentence prompt shown to the user.".to_string()), }, ); + question_props.insert( + "isOther".to_string(), + JsonSchema::Boolean { + description: Some( + "True when this question should include a free-form \"Other\" option. Otherwise false." + .to_string(), + ), + }, + ); question_props.insert("options".to_string(), options_schema); let questions_schema = JsonSchema::Array { @@ -596,6 +605,7 @@ fn create_request_user_input_tool() -> ToolSpec { "id".to_string(), "header".to_string(), "question".to_string(), + "isOther".to_string(), ]), additional_properties: Some(false.into()), }), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 74d3fc98f2..2d81fc9ad8 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -94,6 +94,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", + "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." @@ -213,6 +214,7 @@ where "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", + "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f5579008b2..ee49cac0a6 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -75,7 +75,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - - `EventMsg::RequestUserInput` – Request user input for a tool call + - `EventMsg::RequestUserInput` – Request user input for a tool call (questions can include options plus `isOther` to add a free-form choice) - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index 7c0e02c592..0e58348da4 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -16,6 +16,10 @@ pub struct RequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + #[serde(rename = "isOther", default)] + #[schemars(rename = "isOther")] + #[ts(rename = "isOther")] + pub is_other: bool, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option>, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index a01aa2296b..aaa6391770 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -508,6 +508,7 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), + is_other: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Option 1".to_string(), @@ -530,6 +531,7 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Share details.".to_string(), + is_other: false, options: None, } } @@ -696,6 +698,7 @@ mod tests { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), + is_other: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Discuss a code change (Recommended)".to_string(), From 01d7f8095b54dc3b194def8a4d90de02c93f2fbf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 19:01:35 +0100 Subject: [PATCH 5/8] feat: codex exec mapping of collab tools (#9817) THIS IS NOT THE FINAL UX --- .../src/event_processor_with_human_output.rs | 245 ++++++++++++++- .../src/event_processor_with_jsonl_output.rs | 285 ++++++++++++++++++ codex-rs/exec/src/exec_events.rs | 54 ++++ .../tests/event_processor_with_json_output.rs | 139 +++++++++ 4 files changed, 714 insertions(+), 9 deletions(-) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 76c22caa75..6275ceaf8d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -3,7 +3,16 @@ use codex_common::elapsed::format_elapsed; use codex_core::config::Config; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningRawContentEvent; +use codex_core::protocol::AgentStatus; use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CollabAgentInteractionBeginEvent; +use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabCloseBeginEvent; +use codex_core::protocol::CollabCloseEndEvent; +use codex_core::protocol::CollabWaitingBeginEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; @@ -571,15 +580,161 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::ContextCompacted(_) => { ts_msg!(self, "context compacted"); } - EventMsg::CollabAgentSpawnBegin(_) - | EventMsg::CollabAgentSpawnEnd(_) - | EventMsg::CollabAgentInteractionBegin(_) - | EventMsg::CollabAgentInteractionEnd(_) - | EventMsg::CollabWaitingBegin(_) - | EventMsg::CollabWaitingEnd(_) - | EventMsg::CollabCloseBegin(_) - | EventMsg::CollabCloseEnd(_) => { - // TODO(jif) handle collab tools. + EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { + call_id, + sender_thread_id: _, + prompt, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("spawn_agent", &call_id, Some(&prompt)) + .style(self.bold) + ); + } + EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: _, + new_thread_id, + prompt, + status, + }) => { + let success = new_thread_id.is_some() && !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("spawn_agent", &call_id, Some(&prompt)), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + if let Some(new_thread_id) = new_thread_id { + eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed)); + } + } + EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + prompt, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("send_input", &call_id, Some(&prompt)) + .style(self.bold) + ); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + prompt, + status, + }) => { + let success = !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("send_input", &call_id, Some(&prompt)), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { + sender_thread_id: _, + receiver_thread_ids, + call_id, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("wait", &call_id, None).style(self.bold) + ); + eprintln!( + " receivers: {}", + format_receiver_list(&receiver_thread_ids).style(self.dimmed) + ); + } + EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { + sender_thread_id: _, + call_id, + statuses, + }) => { + if statuses.is_empty() { + ts_msg!( + self, + "{} {}:", + format_collab_invocation("wait", &call_id, None), + "timed out".style(self.yellow) + ); + return CodexStatus::Running; + } + let success = !statuses.values().any(is_collab_status_failure); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {} agents complete:", + format_collab_invocation("wait", &call_id, None), + statuses.len() + ); + ts_msg!(self, "{}", title.style(title_style)); + let mut sorted = statuses + .into_iter() + .map(|(thread_id, status)| (thread_id.to_string(), status)) + .collect::>(); + sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (thread_id, status) in sorted { + eprintln!( + " {} {}", + thread_id.style(self.dimmed), + format_collab_status(&status).style(style_for_agent_status(&status, self)) + ); + } + } + EventMsg::CollabCloseBegin(CollabCloseBeginEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("close_agent", &call_id, None).style(self.bold) + ); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabCloseEnd(CollabCloseEndEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + status, + }) => { + let success = !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("close_agent", &call_id, None), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::WebSearchBegin(_) @@ -654,6 +809,78 @@ fn format_file_change(change: &FileChange) -> &'static str { } } +fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String { + let prompt = prompt + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + .map(|prompt| truncate_preview(prompt, 120)); + match prompt { + Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"), + None => format!("{tool}({call_id})"), + } +} + +fn format_collab_status(status: &AgentStatus) -> String { + match status { + AgentStatus::PendingInit => "pending init".to_string(), + AgentStatus::Running => "running".to_string(), + AgentStatus::Completed(Some(message)) => { + let preview = truncate_preview(message.trim(), 120); + if preview.is_empty() { + "completed".to_string() + } else { + format!("completed: \"{preview}\"") + } + } + AgentStatus::Completed(None) => "completed".to_string(), + AgentStatus::Errored(message) => { + let preview = truncate_preview(message.trim(), 120); + if preview.is_empty() { + "errored".to_string() + } else { + format!("errored: \"{preview}\"") + } + } + AgentStatus::Shutdown => "shutdown".to_string(), + AgentStatus::NotFound => "not found".to_string(), + } +} + +fn style_for_agent_status( + status: &AgentStatus, + processor: &EventProcessorWithHumanOutput, +) -> Style { + match status { + AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed, + AgentStatus::Running => processor.cyan, + AgentStatus::Completed(_) => processor.green, + AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red, + } +} + +fn is_collab_status_failure(status: &AgentStatus) -> bool { + matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) +} + +fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String { + if ids.is_empty() { + return "none".to_string(); + } + ids.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +fn truncate_preview(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + + let preview = text.chars().take(max_chars).collect::(); + format!("{preview}…") +} + fn format_mcp_invocation(invocation: &McpInvocation) -> String { // Build fully-qualified tool name: server.tool let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 3679b57380..779ec9292d 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -6,6 +6,11 @@ use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; +use crate::exec_events::CollabAgentState; +use crate::exec_events::CollabAgentStatus; +use crate::exec_events::CollabTool; +use crate::exec_events::CollabToolCallItem; +use crate::exec_events::CollabToolCallStatus; use crate::exec_events::CommandExecutionItem; use crate::exec_events::CommandExecutionStatus; use crate::exec_events::ErrorItem; @@ -35,6 +40,15 @@ use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; use codex_core::config::Config; use codex_core::protocol; +use codex_core::protocol::AgentStatus as CoreAgentStatus; +use codex_core::protocol::CollabAgentInteractionBeginEvent; +use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabCloseBeginEvent; +use codex_core::protocol::CollabCloseEndEvent; +use codex_core::protocol::CollabWaitingBeginEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use serde_json::Value as JsonValue; @@ -51,6 +65,7 @@ pub struct EventProcessorWithJsonOutput { running_todo_list: Option, last_total_token_usage: Option, running_mcp_tool_calls: HashMap, + running_collab_tool_calls: HashMap, last_critical_error: Option, } @@ -75,6 +90,12 @@ struct RunningMcpToolCall { arguments: JsonValue, } +#[derive(Debug, Clone)] +struct RunningCollabToolCall { + tool: CollabTool, + item_id: String, +} + impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { @@ -85,6 +106,7 @@ impl EventProcessorWithJsonOutput { running_todo_list: None, last_total_token_usage: None, running_mcp_tool_calls: HashMap::new(), + running_collab_tool_calls: HashMap::new(), last_critical_error: None, } } @@ -102,6 +124,18 @@ impl EventProcessorWithJsonOutput { } protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), + protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev), + protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev), + protocol::EventMsg::CollabAgentInteractionBegin(ev) => { + self.handle_collab_interaction_begin(ev) + } + protocol::EventMsg::CollabAgentInteractionEnd(ev) => { + self.handle_collab_interaction_end(ev) + } + protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev), + protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev), + protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev), + protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), protocol::EventMsg::WebSearchBegin(_) => Vec::new(), @@ -341,6 +375,219 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::SpawnAgent, + ev.sender_thread_id.to_string(), + Vec::new(), + Some(ev.prompt.clone()), + ) + } + + fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec { + let (receiver_thread_ids, agents_states) = match ev.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, agent_state)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), + }; + let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) { + CollabToolCallStatus::Completed + } else { + CollabToolCallStatus::Failed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::SpawnAgent, + ev.sender_thread_id.to_string(), + receiver_thread_ids, + Some(ev.prompt.clone()), + agents_states, + status, + ) + } + + fn handle_collab_interaction_begin( + &mut self, + ev: &CollabAgentInteractionBeginEvent, + ) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::SendInput, + ev.sender_thread_id.to_string(), + vec![ev.receiver_thread_id.to_string()], + Some(ev.prompt.clone()), + ) + } + + fn handle_collab_interaction_end( + &mut self, + ev: &CollabAgentInteractionEndEvent, + ) -> Vec { + let receiver_id = ev.receiver_thread_id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + let status = if is_collab_failure(&ev.status) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::SendInput, + ev.sender_thread_id.to_string(), + vec![receiver_id.clone()], + Some(ev.prompt.clone()), + [(receiver_id, agent_state)].into_iter().collect(), + status, + ) + } + + fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::Wait, + ev.sender_thread_id.to_string(), + ev.receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(), + None, + ) + } + + fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec { + let status = if ev.statuses.values().any(is_collab_failure) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + let mut receiver_thread_ids = ev + .statuses + .keys() + .map(ToString::to_string) + .collect::>(); + receiver_thread_ids.sort(); + let agents_states = ev + .statuses + .iter() + .map(|(thread_id, status)| { + ( + thread_id.to_string(), + CollabAgentState::from(status.clone()), + ) + }) + .collect(); + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::Wait, + ev.sender_thread_id.to_string(), + receiver_thread_ids, + None, + agents_states, + status, + ) + } + + fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::CloseAgent, + ev.sender_thread_id.to_string(), + vec![ev.receiver_thread_id.to_string()], + None, + ) + } + + fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec { + let receiver_id = ev.receiver_thread_id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + let status = if is_collab_failure(&ev.status) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::CloseAgent, + ev.sender_thread_id.to_string(), + vec![receiver_id.clone()], + None, + [(receiver_id, agent_state)].into_iter().collect(), + status, + ) + } + + fn start_collab_tool_call( + &mut self, + call_id: &str, + tool: CollabTool, + sender_thread_id: String, + receiver_thread_ids: Vec, + prompt: Option, + ) -> Vec { + let item_id = self.get_next_item_id(); + self.running_collab_tool_calls.insert( + call_id.to_string(), + RunningCollabToolCall { + tool: tool.clone(), + item_id: item_id.clone(), + }, + ); + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states: HashMap::new(), + status: CollabToolCallStatus::InProgress, + }), + }; + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + } + + #[allow(clippy::too_many_arguments)] + fn finish_collab_tool_call( + &mut self, + call_id: &str, + tool: CollabTool, + sender_thread_id: String, + receiver_thread_ids: Vec, + prompt: Option, + agents_states: HashMap, + status: CollabToolCallStatus, + ) -> Vec { + let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) { + Some(running) => (running.tool, running.item_id), + None => { + warn!( + call_id, + "Received collab tool end without begin; synthesizing new item" + ); + (tool, self.get_next_item_id()) + } + }; + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states, + status, + }), + }; + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + } + fn handle_patch_apply_begin( &mut self, ev: &protocol::PatchApplyBeginEvent, @@ -512,6 +759,44 @@ impl EventProcessorWithJsonOutput { } } +fn is_collab_failure(status: &CoreAgentStatus) -> bool { + matches!( + status, + CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound + ) +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + impl EventProcessor for EventProcessorWithJsonOutput { fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) { self.process_event(protocol::Event { diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index f3726dad76..e113a5c4b4 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -2,6 +2,7 @@ use mcp_types::ContentBlock as McpContentBlock; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashMap; use ts_rs::TS; /// Top-level JSONL events emitted by codex exec @@ -113,6 +114,9 @@ pub enum ThreadItemDetails { /// Represents a call to an MCP tool. The item starts when the invocation is /// dispatched and completes when the MCP server reports success or failure. McpToolCall(McpToolCallItem), + /// Represents a call to a collab tool. The item starts when the collab tool is + /// invoked and completes when the collab tool reports success or failure. + CollabToolCall(CollabToolCallItem), /// Captures a web search request. It starts when the search is kicked off /// and completes when results are returned to the agent. WebSearch(WebSearchItem), @@ -198,6 +202,56 @@ pub enum McpToolCallStatus { Failed, } +/// The status of a collab tool call. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabToolCallStatus { + #[default] + InProgress, + Completed, + Failed, +} + +/// Supported collab tools. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabTool { + SpawnAgent, + SendInput, + Wait, + CloseAgent, +} + +/// The status of a collab agent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Completed, + Errored, + Shutdown, + NotFound, +} + +/// Last known state of a collab agent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +/// A call to a collab tool. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +pub struct CollabToolCallItem { + pub tool: CollabTool, + pub sender_thread_id: String, + pub receiver_thread_ids: Vec, + pub prompt: Option, + pub agents_states: HashMap, + pub status: CollabToolCallStatus, +} + /// Result payload produced by an MCP tool invocation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct McpToolCallItemResult { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 43de1f95f8..cb2a283b30 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,6 +1,10 @@ use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::AgentStatus; use codex_core::protocol::AskForApproval; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; @@ -19,6 +23,11 @@ use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; +use codex_exec::exec_events::CollabAgentState; +use codex_exec::exec_events::CollabAgentStatus; +use codex_exec::exec_events::CollabTool; +use codex_exec::exec_events::CollabToolCallItem; +use codex_exec::exec_events::CollabToolCallStatus; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; use codex_exec::exec_events::ErrorItem; @@ -44,6 +53,7 @@ use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; +use codex_protocol::ThreadId; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -444,6 +454,135 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { ); } +#[test] +fn collab_spawn_begin_and_end_emit_item_events() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap(); + let prompt = "draft a plan".to_string(); + + let begin = event( + "c1", + EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { + call_id: "call-10".to_string(), + sender_thread_id, + prompt: prompt.clone(), + }), + ); + let begin_events = ep.collect_thread_events(&begin); + assert_eq!( + begin_events, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(prompt.clone()), + agents_states: std::collections::HashMap::new(), + status: CollabToolCallStatus::InProgress, + }), + }, + })] + ); + + let end = event( + "c2", + EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id: "call-10".to_string(), + sender_thread_id, + new_thread_id: Some(new_thread_id), + prompt: prompt.clone(), + status: AgentStatus::Running, + }), + ); + let end_events = ep.collect_thread_events(&end); + assert_eq!( + end_events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![new_thread_id.to_string()], + prompt: Some(prompt), + agents_states: [( + new_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Running, + message: None, + }, + )] + .into_iter() + .collect(), + status: CollabToolCallStatus::Completed, + }), + }, + })] + ); +} + +#[test] +fn collab_wait_end_without_begin_synthesizes_failed_item() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap(); + let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap(); + let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()]; + receiver_thread_ids.sort(); + let mut statuses = std::collections::HashMap::new(); + statuses.insert( + running_thread_id, + AgentStatus::Completed(Some("done".to_string())), + ); + statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string())); + + let end = event( + "c3", + EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { + sender_thread_id, + call_id: "call-11".to_string(), + statuses: statuses.clone(), + }), + ); + let events = ep.collect_thread_events(&end); + assert_eq!( + events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::Wait, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + agents_states: [ + ( + running_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Completed, + message: Some("done".to_string()), + }, + ), + ( + failed_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Errored, + message: Some("boom".to_string()), + }, + ), + ] + .into_iter() + .collect(), + status: CollabToolCallStatus::Failed, + }), + }, + })] + ); +} + #[test] fn plan_update_after_complete_starts_new_todo_list_with_new_id() { let mut ep = EventProcessorWithJsonOutput::new(None); From fdc69df4540c3f57ddd7047c50ddf4a34d8380a8 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 26 Jan 2026 18:05:30 +0000 Subject: [PATCH 6/8] Fix flakey shell snapshot test (#9919) Sometimes fails with: ``` failures: ---- shell_snapshot::tests::timed_out_snapshot_shell_is_terminated stdout ---- thread 'shell_snapshot::tests::timed_out_snapshot_shell_is_terminated' panicked at codex-rs/core/src/shell_snapshot.rs:588:9: expected timeout error, got Failed to execute sh Caused by: Text file busy (os error 26) failures: shell_snapshot::tests::timed_out_snapshot_shell_is_terminated test result: FAILED. 815 passed; 1 failed; 4 ignored; 0 measured; 0 filtered out; finished in 18.00s ``` --- codex-rs/core/src/shell_snapshot.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b578c4232f..78581186c7 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -464,8 +464,6 @@ mod tests { use pretty_assertions::assert_eq; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; - #[cfg(target_os = "linux")] - use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::process::Command; #[cfg(target_os = "linux")] @@ -562,27 +560,16 @@ mod tests { use tokio::time::sleep; let dir = tempdir()?; - let shell_path = dir.path().join("hanging-shell.sh"); let pid_path = dir.path().join("pid"); - - let script = format!( - "#!/bin/sh\n\ - echo $$ > {}\n\ - sleep 30\n", - pid_path.display() - ); - fs::write(&shell_path, script).await?; - let mut permissions = std::fs::metadata(&shell_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&shell_path, permissions)?; + let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display()); let shell = Shell { shell_type: ShellType::Sh, - shell_path, + shell_path: PathBuf::from("/bin/sh"), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let err = run_script_with_timeout(&shell, "ignored", Duration::from_millis(500), true) + let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true) .await .expect_err("snapshot shell should time out"); assert!( From 375a5ef05116a3d289dc548402387f86e28851cc Mon Sep 17 00:00:00 2001 From: Gene Oden Date: Mon, 26 Jan 2026 10:07:25 -0800 Subject: [PATCH 7/8] fix: attempt to reduce high cpu usage when using collab (#9776) Reproduce with a prompt like this with collab enabled: ``` Examine the code at . Find the most urgent issue to resolve and describe it to me. ``` Existing behavior causes the top-level agent to busy wait on subagents. --- codex-rs/core/src/tools/handlers/collab.rs | 39 ++++++++++++++++++- codex-rs/core/src/tools/spec.rs | 3 +- .../core/templates/agents/orchestrator.md | 1 + 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 00190648f0..b1666949f6 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -28,6 +28,8 @@ use serde::Serialize; pub struct CollabHandler; +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 300_000; @@ -323,6 +325,8 @@ mod wait { .collect::, _>>()?; // Validate timeout. + // Very short timeouts encourage busy-polling loops in the orchestrator prompt and can + // cause high CPU usage even with a single active worker, so clamp to a minimum. let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); let timeout_ms = match timeout_ms { ms if ms <= 0 => { @@ -330,7 +334,7 @@ mod wait { "timeout_ms must be greater than zero".to_owned(), )); } - ms => ms.min(MAX_WAIT_TIMEOUT_MS), + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), }; session @@ -1012,7 +1016,7 @@ mod tests { "wait", function_payload(json!({ "ids": [agent_id.to_string()], - "timeout_ms": 10 + "timeout_ms": MIN_WAIT_TIMEOUT_MS })), ); let output = CollabHandler @@ -1043,6 +1047,37 @@ mod tests { .expect("shutdown should submit"); } + #[tokio::test] + async fn wait_clamps_short_timeouts_to_minimum() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.client.config().as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 10 + })), + ); + + let early = timeout(Duration::from_millis(50), CollabHandler.handle(invocation)).await; + assert!( + early.is_err(), + "wait should not return before the minimum timeout clamp" + ); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + } + #[tokio::test] async fn wait_returns_final_status_without_timeout() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c06b9556e7..33010e58e1 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::collab::MIN_WAIT_TIMEOUT_MS; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -517,7 +518,7 @@ fn create_wait_tool() -> ToolSpec { "timeout_ms".to_string(), JsonSchema::Number { description: Some(format!( - "Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS} and max {MAX_WAIT_TIMEOUT_MS}." + "Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS}, min {MIN_WAIT_TIMEOUT_MS}, and max {MAX_WAIT_TIMEOUT_MS}. Avoid tight polling loops; prefer longer waits (seconds to minutes)." )), }, ); diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 09b6ea0b4e..c303591d48 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -52,6 +52,7 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration * Workers must not revert, overwrite, or conflict with others’ work. * By default, workers must not spawn sub-agents unless explicitly allowed. * When multiple workers are active, you may pass multiple IDs to `wait` to react to the first completion and keep the workflow event-driven and use a long timeout (e.g. 5 minutes). +* Do not busy-poll `wait` with very short timeouts. Prefer waits measured in seconds (or minutes) so the system is idle while workers run. ## Collab tools From e471ebc5d241bcd355855585cde8409e5dc34916 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 10:27:18 -0800 Subject: [PATCH 8/8] prompt (#9928) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/templates/collaboration_mode/plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 3d0447c729..b8e0af03f1 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -2,6 +2,10 @@ You work in 2 phases and you should *chat your way* to a great plan before finalizing it. +While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it. + +User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. + PHASE 1 — Intent chat (what they actually want) - Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. - Bias toward questions over guessing: if any high‑impact ambiguity remains, do NOT plan yet—ask.