diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 01ea1a7142..3cb3277531 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -263,6 +263,15 @@ pub(crate) struct Session { /// Manager for external MCP servers/tools. mcp_connection_manager: McpConnectionManager, + /// Loaded subagent definitions from project and user scope. + subagents_registry: crate::subagents::registry::SubagentRegistry, + + /// Auth manager used to spawn nested sessions (e.g., subagents). + auth_manager: Arc, + + /// Base configuration used to derive nested session configs. + base_config: Arc, + /// External notifier command (will be passed as args to exec()). When /// `None` this feature is disabled. notify: Option>, @@ -498,6 +507,31 @@ impl Session { model_reasoning_summary, session_id, ); + // Build subagent registry paths and load once per session + let project_agents_dir = { + let mut p = cwd.clone(); + p.push(".codex"); + p.push("agents"); + if p.exists() { Some(p) } else { None } + }; + let user_agents_dir = { + let mut p = config.codex_home.clone(); + p.push("agents"); + if p.exists() { Some(p) } else { None } + }; + let mut subagents_registry = + crate::subagents::registry::SubagentRegistry::new(project_agents_dir, user_agents_dir); + subagents_registry.load(); + // Log discovered subagents for visibility in clients (e.g., TUI). + let _ = tx_event + .send(Event { + id: INITIAL_SUBMIT_ID.to_string(), + msg: EventMsg::BackgroundEvent(BackgroundEventEvent { + message: format!("subagents discovered: {:?}", subagents_registry.all_names()), + }), + }) + .await; + let turn_context = TurnContext { client, tools_config: ToolsConfig::new( @@ -506,6 +540,7 @@ impl Session { sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.include_subagent_tool, ), user_instructions, base_instructions, @@ -519,6 +554,9 @@ impl Session { session_id, tx_event: tx_event.clone(), mcp_connection_manager, + subagents_registry, + auth_manager: auth_manager.clone(), + base_config: config.clone(), notify, state: Mutex::new(state), rollout: Mutex::new(rollout_recorder), @@ -578,6 +616,16 @@ impl Session { } } + /// Access auth manager for nested sessions. + pub(crate) fn auth_manager(&self) -> Arc { + self.auth_manager.clone() + } + + /// Access base config for nested sessions. + pub(crate) fn base_config(&self) -> Arc { + self.base_config.clone() + } + /// Sends the given event to the client and swallows the send event, if /// any, logging it as an error. pub(crate) async fn send_event(&self, event: Event) { @@ -1090,6 +1138,7 @@ async fn submission_loop( new_sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.include_subagent_tool, ); let new_turn_context = TurnContext { @@ -1168,6 +1217,7 @@ async fn submission_loop( sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.include_subagent_tool, ), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -1555,6 +1605,26 @@ async fn run_turn( Some(sess.mcp_connection_manager.list_all_tools()), ); + // Log tool names for visibility in the TUI/debug logs. + #[allow(clippy::match_same_arms)] + let tool_names: Vec = tools + .iter() + .map(|t| match t { + crate::openai_tools::OpenAiTool::Function(f) => f.name.clone(), + crate::openai_tools::OpenAiTool::LocalShell {} => "local_shell".to_string(), + crate::openai_tools::OpenAiTool::Freeform(f) => f.name.clone(), + }) + .collect(); + let _ = sess + .tx_event + .send(Event { + id: sub_id.clone(), + msg: EventMsg::BackgroundEvent(BackgroundEventEvent { + message: format!("tools available: {:?}", tool_names), + }), + }) + .await; + let prompt = Prompt { input, store: !turn_context.disable_response_storage, @@ -2073,6 +2143,57 @@ async fn handle_function_call( .await } "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, + "subagent.run" => { + #[derive(serde::Deserialize)] + struct Args { + name: String, + input: String, + #[serde(default)] + context: Option, + } + let args = match serde_json::from_str::(&arguments) { + Ok(a) => a, + Err(e) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: Some(false), + }, + }; + } + }; + + let result = crate::subagents::runner::run( + sess, + turn_context, + &sess.subagents_registry, + crate::subagents::runner::RunSubagentArgs { + name: args.name, + input: args.input, + context: args.context, + }, + &sub_id, + ) + .await; + + match result { + Ok(message) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: message, + success: Some(true), + }, + }, + Err(e) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("subagent failed: {e}"), + success: Some(false), + }, + }, + } + } _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { @@ -2183,6 +2304,8 @@ fn parse_container_exec_arguments( } } +// (helper run_one_turn_collect removed as unused) + pub struct ExecInvokeArgs<'a> { pub params: ExecParams, pub sandbox_type: SandboxType, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 67a54eb1d7..ab90390141 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -169,6 +169,9 @@ pub struct Config { /// model family's default preference. pub include_apply_patch_tool: bool, + /// Include the `subagent.run` tool allowing the model to invoke configured subagents. + pub include_subagent_tool: bool, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header: String, @@ -476,6 +479,9 @@ pub struct ConfigToml { /// If set to `true`, the API key will be signed with the `originator` header. pub preferred_auth_method: Option, + + /// Include the `subagent.run` tool allowing the model to invoke configured subagents. + pub include_subagent_tool: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -570,6 +576,7 @@ pub struct ConfigOverrides { pub base_instructions: Option, pub include_plan_tool: Option, pub include_apply_patch_tool: Option, + pub include_subagent_tool: Option, pub disable_response_storage: Option, pub show_raw_agent_reasoning: Option, } @@ -596,6 +603,7 @@ impl Config { base_instructions, include_plan_tool, include_apply_patch_tool, + include_subagent_tool, disable_response_storage, show_raw_agent_reasoning, } = overrides; @@ -756,6 +764,11 @@ impl Config { experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false), + include_subagent_tool: config_profile + .include_subagent_tool + .or(cfg.include_subagent_tool) + .or(include_subagent_tool) + .unwrap_or(false), responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), }; @@ -1122,6 +1135,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }, @@ -1176,6 +1190,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }; @@ -1245,6 +1260,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }; diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index 54869919fb..3562460a80 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -21,4 +21,6 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub chatgpt_base_url: Option, pub experimental_instructions_file: Option, + /// Include the `subagent.run` tool allowing the model to invoke configured subagents. + pub include_subagent_tool: Option, } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6d4699bceb..f92aa8a724 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -62,3 +62,4 @@ pub use codex_protocol::protocol; // Re-export protocol config enums to ensure call sites can use the same types // as those in the protocol crate when constructing protocol messages. pub use codex_protocol::config_types as protocol_config_types; +pub mod subagents; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index bb5e6dacbf..ad2543f618 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -63,6 +63,7 @@ pub struct ToolsConfig { pub shell_type: ConfigShellToolType, pub plan_tool: bool, pub apply_patch_tool_type: Option, + pub subagent_tool: bool, } impl ToolsConfig { @@ -72,6 +73,7 @@ impl ToolsConfig { sandbox_policy: SandboxPolicy, include_plan_tool: bool, include_apply_patch_tool: bool, + include_subagent_tool: bool, ) -> Self { let mut shell_type = if model_family.uses_local_shell_tool { ConfigShellToolType::LocalShell @@ -100,6 +102,7 @@ impl ToolsConfig { shell_type, plan_tool: include_plan_tool, apply_patch_tool_type, + subagent_tool: include_subagent_tool, } } } @@ -509,6 +512,11 @@ pub(crate) fn get_openai_tools( } } + if config.subagent_tool { + tracing::trace!("Adding subagent tool"); + tools.push(crate::subagents::SUBAGENT_TOOL.clone()); + } + if let Some(mcp_tools) = mcp_tools { for (name, tool) in mcp_tools { match mcp_tool_to_openai_tool(name.clone(), tool.clone()) { @@ -520,6 +528,7 @@ pub(crate) fn get_openai_tools( } } + tracing::trace!("Tools: {tools:?}"); tools } @@ -564,6 +573,7 @@ mod tests { SandboxPolicy::ReadOnly, true, false, + false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -579,6 +589,7 @@ mod tests { SandboxPolicy::ReadOnly, true, false, + false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -594,6 +605,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + false, ); let tools = get_openai_tools( &config, @@ -688,6 +700,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + false, ); let tools = get_openai_tools( @@ -744,6 +757,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + false, ); let tools = get_openai_tools( @@ -795,6 +809,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + false, ); let tools = get_openai_tools( @@ -849,6 +864,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + false, ); let tools = get_openai_tools( 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 0f7e14ea40..3a5c0ed9b0 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -168,6 +168,15 @@ impl EventProcessor for EventProcessorWithHumanOutput { fn process_event(&mut self, event: Event) -> CodexStatus { let Event { id: _, msg } = event; match msg { + EventMsg::SubagentBegin(_) => { + // Ignore in human output for now. + } + EventMsg::SubagentForwarded(_) => { + // Ignore; TUI will render forwarded events. + } + EventMsg::SubagentEnd(_) => { + // Ignore in human output for now. + } EventMsg::Error(ErrorEvent { message }) => { let prefix = "ERROR:".style(self.red); ts_println!(self, "{prefix} {message}"); diff --git a/codex-rs/exec/src/event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_json_output.rs index 11e9732e99..e92f315f0f 100644 --- a/codex-rs/exec/src/event_processor_with_json_output.rs +++ b/codex-rs/exec/src/event_processor_with_json_output.rs @@ -41,6 +41,12 @@ impl EventProcessor for EventProcessorWithJsonOutput { fn process_event(&mut self, event: Event) -> CodexStatus { match event.msg { + EventMsg::SubagentBegin(_) + | EventMsg::SubagentForwarded(_) + | EventMsg::SubagentEnd(_) => { + // Ignored for JSON output in exec for now. + CodexStatus::Running + } EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { // Suppress streaming events in JSON mode. CodexStatus::Running diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d403cb7960..6d2b6ccc5d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -216,7 +216,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any Ok(event) => { debug!("Received event: {event:?}"); - let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete); + let is_shutdown_complete = matches!( + event.msg, + EventMsg::ShutdownComplete + | EventMsg::SubagentBegin(_) + | EventMsg::SubagentForwarded(_) + | EventMsg::SubagentEnd(_) + ); if let Err(e) = tx.send(event) { error!("Error sending event: {e:?}"); break; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c6d65bc89d..fd81f2520d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -174,6 +174,11 @@ async fn run_codex_tool_session_inner( .await; match event.msg { + EventMsg::SubagentBegin(_) + | EventMsg::SubagentForwarded(_) + | EventMsg::SubagentEnd(_) => { + // Ignore subagent orchestration for MCP echoing. + } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { command, cwd, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7e7708b23c..38dd12f99d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -478,6 +478,14 @@ pub enum EventMsg { ShutdownComplete, ConversationHistory(ConversationHistoryResponseEvent), + + // --- Subagent orchestration events --- + /// Emitted when a subagent starts. + SubagentBegin(SubagentBeginEvent), + /// Forwards a nested event produced by a running subagent. + SubagentForwarded(SubagentForwardedEvent), + /// Emitted when a subagent finishes. + SubagentEnd(SubagentEndEvent), } // Individual event payload types matching each `EventMsg` variant. @@ -501,6 +509,28 @@ pub struct TokenUsage { pub total_tokens: u64, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubagentBeginEvent { + pub subagent_id: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubagentEndEvent { + pub subagent_id: String, + pub name: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_agent_message: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubagentForwardedEvent { + pub subagent_id: String, + pub name: String, + pub event: Box, +} + impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3f24a0176c..8d0cf8d2be 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -836,8 +836,39 @@ impl ChatWidget { EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + // Also show background logs in the transcript for visibility. + self.add_to_history(history_cell::new_log_line(message.clone())); self.on_background_event(message) } + EventMsg::SubagentBegin(ev) => { + let msg = format!("subagent begin: {} ({})", ev.name, ev.subagent_id); + self.add_to_history(history_cell::new_log_line(msg)); + } + EventMsg::SubagentForwarded(ev) => { + // Summarize forwarded event type; include message text when it is AgentMessage. + match *ev.event { + EventMsg::AgentMessage(AgentMessageEvent { message }) => { + let msg = format!("subagent {}: {}", ev.name, message); + self.add_to_history(history_cell::new_log_line(msg)); + } + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { ref delta }) => { + let msg = format!("subagent {}: {}", ev.name, delta); + self.add_to_history(history_cell::new_log_line(msg)); + } + ref other => { + let msg = format!("subagent {} forwarded: {:?}", ev.name, other); + self.add_to_history(history_cell::new_log_line(msg)); + } + } + } + EventMsg::SubagentEnd(ev) => { + let summary = ev.last_agent_message.as_deref().unwrap_or(""); + let msg = format!( + "subagent end: {} ({}) success={} {}", + ev.name, ev.subagent_id, ev.success, summary + ); + self.add_to_history(history_cell::new_log_line(msg)); + } EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), EventMsg::ConversationHistory(_) => {} } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3e51ffd036..47a3833e59 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -750,6 +750,14 @@ pub(crate) fn new_status_output( PlainHistoryCell { lines } } +/// Simple one-line log entry (dim) to surface traces and diagnostics in the transcript. +pub(crate) fn new_log_line(message: String) -> TranscriptOnlyHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(message).dim()); + TranscriptOnlyHistoryCell { lines } +} + /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d586c202ac..46350d0a16 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -124,6 +124,7 @@ pub async fn run_main( config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, base_instructions: None, + include_subagent_tool: None, include_plan_tool: Some(true), include_apply_patch_tool: None, disable_response_storage: cli.oss.then_some(true),