diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3cb3277531..7d77445eba 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -522,15 +522,14 @@ impl Session { 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; + // Log discovered subagents for visibility in clients (e.g., TUI) after + // SessionConfigured so the first event contract remains intact. + post_session_configured_error_events.push(Event { + id: INITIAL_SUBMIT_ID.to_string(), + msg: EventMsg::BackgroundEvent(BackgroundEventEvent { + message: format!("subagents discovered: {:?}", subagents_registry.all_names()), + }), + }); let turn_context = TurnContext { client, @@ -1604,6 +1603,7 @@ async fn run_turn( &turn_context.tools_config, Some(sess.mcp_connection_manager.list_all_tools()), ); + tracing::trace!("Tools: {tools:?}"); // Log tool names for visibility in the TUI/debug logs. #[allow(clippy::match_same_arms)] @@ -2143,7 +2143,7 @@ async fn handle_function_call( .await } "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, - "subagent.run" => { + "subagent_run" => { #[derive(serde::Deserialize)] struct Args { name: String, @@ -2194,6 +2194,33 @@ async fn handle_function_call( }, } } + "subagent_list" => { + #[derive(serde::Serialize)] + struct SubagentBrief<'a> { + name: &'a str, + description: &'a str, + } + let mut list = Vec::new(); + for name in sess.subagents_registry.all_names() { + if let Some(def) = sess.subagents_registry.get(&name) { + list.push(SubagentBrief { + name: &def.name, + description: &def.description, + }); + } + } + let payload = match serde_json::to_string(&list) { + Ok(s) => s, + Err(e) => format!("failed to serialize subagent list: {e}"), + }; + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: payload, + success: Some(true), + }, + } + } _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index ad2543f618..c8a45935de 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -515,6 +515,7 @@ pub(crate) fn get_openai_tools( if config.subagent_tool { tracing::trace!("Adding subagent tool"); tools.push(crate::subagents::SUBAGENT_TOOL.clone()); + tools.push(crate::subagents::SUBAGENT_LIST_TOOL.clone()); } if let Some(mcp_tools) = mcp_tools { diff --git a/codex-rs/core/src/subagents/definition.rs b/codex-rs/core/src/subagents/definition.rs new file mode 100644 index 0000000000..f7f7d2384b --- /dev/null +++ b/codex-rs/core/src/subagents/definition.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize)] +pub struct SubagentDefinition { + pub name: String, + pub description: String, + /// Base instructions for this subagent. + pub instructions: String, + /// When not set, inherits the parent agent's tool set. When set to an + /// empty list, no tools are available to the subagent. + #[serde(default)] + pub tools: Option>, // None => inherit; Some(vec) => allow-list +} + +impl SubagentDefinition { + pub fn from_json_str(s: &str) -> Result { + serde_json::from_str::(s) + } + + pub fn from_file(path: &Path) -> std::io::Result { + let contents = fs::read_to_string(path)?; + // Surface JSON parsing error with file context + serde_json::from_str::(&contents).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid subagent JSON at {}: {e}", path.display()), + ) + }) + } +} diff --git a/codex-rs/core/src/subagents/mod.rs b/codex-rs/core/src/subagents/mod.rs new file mode 100644 index 0000000000..765da8fd2d --- /dev/null +++ b/codex-rs/core/src/subagents/mod.rs @@ -0,0 +1,6 @@ +pub mod definition; +pub mod registry; +pub mod runner; +pub mod tool; + +pub(crate) use tool::{SUBAGENT_LIST_TOOL, SUBAGENT_TOOL}; diff --git a/codex-rs/core/src/subagents/registry.rs b/codex-rs/core/src/subagents/registry.rs new file mode 100644 index 0000000000..b253652494 --- /dev/null +++ b/codex-rs/core/src/subagents/registry.rs @@ -0,0 +1,92 @@ +use super::definition::SubagentDefinition; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Default, Clone)] +pub struct SubagentRegistry { + /// Directory under the project (cwd/.codex/agents). + project_dir: Option, + /// Directory under CODEX_HOME (~/.codex/agents). + user_dir: Option, + /// Merged map: project definitions override user ones. + map: HashMap, +} + +impl SubagentRegistry { + pub fn new(project_dir: Option, user_dir: Option) -> Self { + Self { + project_dir, + user_dir, + map: HashMap::new(), + } + } + + /// Loads JSON files from user_dir then project_dir (project wins on conflict). + pub fn load(&mut self) { + let mut map: HashMap = HashMap::new(); + + // Load user definitions first + if let Some(dir) = &self.user_dir { + Self::load_from_dir_into(dir, &mut map); + } + // Then load project definitions which override on conflicts + if let Some(dir) = &self.project_dir { + Self::load_from_dir_into(dir, &mut map); + } + + // Ensure a simple built‑in test subagent exists to validate wiring end‑to‑end. + // Users can override this by providing their own definition named "hello". + if !map.contains_key("hello") { + map.insert( + "hello".to_string(), + SubagentDefinition { + name: "hello".to_string(), + description: "Built‑in test subagent that replies with a greeting".to_string(), + // Keep instructions narrow so models reliably output the intended text. + instructions: + "Reply with exactly this text and nothing else: Hello from subagent" + .to_string(), + // Disallow tool usage for the hello subagent. + tools: Some(Vec::new()), + }, + ); + } + + self.map = map; + } + + pub fn get(&self, name: &str) -> Option<&SubagentDefinition> { + self.map.get(name) + } + + pub fn all_names(&self) -> Vec { + self.map.keys().cloned().collect() + } + + fn load_from_dir_into(dir: &Path, out: &mut HashMap) { + let Ok(iter) = fs::read_dir(dir) else { + return; + }; + for entry in iter.flatten() { + let path = entry.path(); + if path.is_file() + && path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("json")) + .unwrap_or(false) + { + match SubagentDefinition::from_file(&path) { + Ok(def) => { + out.insert(def.name.clone(), def); + } + Err(e) => { + tracing::warn!("Failed to load subagent from {}: {}", path.display(), e); + } + } + } + } + } +} diff --git a/codex-rs/core/src/subagents/runner.rs b/codex-rs/core/src/subagents/runner.rs new file mode 100644 index 0000000000..366aa64830 --- /dev/null +++ b/codex-rs/core/src/subagents/runner.rs @@ -0,0 +1,142 @@ +use crate::codex::Codex; +use crate::error::Result as CodexResult; + +use super::definition::SubagentDefinition; +use super::registry::SubagentRegistry; + +/// Arguments expected for the `subagent.run` tool. +#[derive(serde::Deserialize)] +pub struct RunSubagentArgs { + pub name: String, + pub input: String, + #[serde(default)] + pub context: Option, +} + +/// Run a subagent in a nested Codex session and return the final message. +pub(crate) async fn run( + sess: &crate::codex::Session, + turn_context: &crate::codex::TurnContext, + registry: &SubagentRegistry, + args: RunSubagentArgs, + _parent_sub_id: &str, +) -> CodexResult { + let def: &SubagentDefinition = registry.get(&args.name).ok_or_else(|| { + crate::error::CodexErr::Stream(format!("unknown subagent: {}", args.name), None) + })?; + + let mut nested_cfg = (*sess.base_config()).clone(); + nested_cfg.base_instructions = Some(def.instructions.clone()); + nested_cfg.user_instructions = None; + nested_cfg.approval_policy = turn_context.approval_policy; + nested_cfg.sandbox_policy = turn_context.sandbox_policy.clone(); + nested_cfg.cwd = turn_context.cwd.clone(); + nested_cfg.include_subagent_tool = false; + + let nested = Codex::spawn(nested_cfg, sess.auth_manager(), None).await?; + let nested_codex = nested.codex; + + let subagent_id = uuid::Uuid::new_v4().to_string(); + forward_begin(sess, _parent_sub_id, &subagent_id, &def.name).await; + + let text = match args.context { + Some(ctx) if !ctx.trim().is_empty() => format!("{ctx}\n\n{input}", input = args.input), + _ => args.input, + }; + + nested_codex + .submit(crate::protocol::Op::UserInput { + items: vec![crate::protocol::InputItem::Text { text }], + }) + .await + .map_err(|e| { + crate::error::CodexErr::Stream(format!("failed to submit to subagent: {e}"), None) + })?; + + let mut last_message: Option = None; + loop { + let ev = nested_codex.next_event().await?; + match ev.msg.clone() { + crate::protocol::EventMsg::AgentMessage(m) => { + last_message = Some(m.message); + } + crate::protocol::EventMsg::TaskComplete(t) => { + let _ = nested_codex.submit(crate::protocol::Op::Shutdown).await; + forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await; + forward_end( + sess, + _parent_sub_id, + &subagent_id, + &def.name, + true, + t.last_agent_message.clone(), + ) + .await; + return Ok(t + .last_agent_message + .unwrap_or_else(|| last_message.unwrap_or_default())); + } + _ => {} + } + forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await; + } +} + +async fn forward_begin( + sess: &crate::codex::Session, + parent_sub_id: &str, + subagent_id: &str, + name: &str, +) { + sess + .send_event(crate::protocol::Event { + id: parent_sub_id.to_string(), + msg: crate::protocol::EventMsg::SubagentBegin(crate::protocol::SubagentBeginEvent { + subagent_id: subagent_id.to_string(), + name: name.to_string(), + }), + }) + .await; +} + +async fn forward_forwarded( + sess: &crate::codex::Session, + parent_sub_id: &str, + subagent_id: &str, + name: &str, + msg: crate::protocol::EventMsg, +) { + sess + .send_event(crate::protocol::Event { + id: parent_sub_id.to_string(), + msg: crate::protocol::EventMsg::SubagentForwarded( + crate::protocol::SubagentForwardedEvent { + subagent_id: subagent_id.to_string(), + name: name.to_string(), + event: Box::new(msg), + }, + ), + }) + .await; +} + +async fn forward_end( + sess: &crate::codex::Session, + parent_sub_id: &str, + subagent_id: &str, + name: &str, + success: bool, + last_agent_message: Option, +) { + sess + .send_event(crate::protocol::Event { + id: parent_sub_id.to_string(), + msg: crate::protocol::EventMsg::SubagentEnd(crate::protocol::SubagentEndEvent { + subagent_id: subagent_id.to_string(), + name: name.to_string(), + success, + last_agent_message, + }), + }) + .await; +} diff --git a/codex-rs/core/src/subagents/tool.rs b/codex-rs/core/src/subagents/tool.rs new file mode 100644 index 0000000000..7a7109fd3c --- /dev/null +++ b/codex-rs/core/src/subagents/tool.rs @@ -0,0 +1,54 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use crate::openai_tools::JsonSchema; +use crate::openai_tools::OpenAiTool; +use crate::openai_tools::ResponsesApiTool; + +pub(crate) static SUBAGENT_TOOL: LazyLock = LazyLock::new(|| { + let mut properties = BTreeMap::new(); + properties.insert( + "name".to_string(), + JsonSchema::String { + description: Some("Registered subagent name".to_string()), + }, + ); + properties.insert( + "input".to_string(), + JsonSchema::String { + description: Some("Task or instruction for the subagent".to_string()), + }, + ); + properties.insert( + "context".to_string(), + JsonSchema::String { + description: Some("Optional extra context to aid the task".to_string()), + }, + ); + + OpenAiTool::Function(ResponsesApiTool { + name: "subagent_run".to_string(), + description: "Invoke a named subagent with isolated context and return its result" + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["name".to_string(), "input".to_string()]), + additional_properties: Some(false), + }, + }) +}); + +pub(crate) static SUBAGENT_LIST_TOOL: LazyLock = LazyLock::new(|| { + let properties = BTreeMap::new(); + OpenAiTool::Function(ResponsesApiTool { + name: "subagent_list".to_string(), + description: "List available subagents (name and description). Call before subagent_run if unsure.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false), + }, + }) +}); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6d2b6ccc5d..637fc10fcd 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -146,6 +146,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider, codex_linux_sandbox_exe, base_instructions: None, + include_subagent_tool: None, include_plan_tool: None, include_apply_patch_tool: None, disable_response_storage: oss.then_some(true), @@ -216,13 +217,7 @@ 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 - | EventMsg::SubagentBegin(_) - | EventMsg::SubagentForwarded(_) - | EventMsg::SubagentEnd(_) - ); + let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete); if let Err(e) = tx.send(event) { error!("Error sending event: {e:?}"); break; diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 0bbf6ff849..48e632b039 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -736,6 +736,7 @@ fn derive_config_from_params( base_instructions, include_plan_tool, include_apply_patch_tool, + include_subagent_tool: None, disable_response_storage: None, show_raw_agent_reasoning: None, }; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 5993c10faf..0513bb8eb2 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -161,6 +161,7 @@ impl CodexToolCallParam { base_instructions, include_plan_tool, include_apply_patch_tool: None, + include_subagent_tool: None, disable_response_storage: None, show_raw_agent_reasoning: None, }; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 47a3833e59..57848b10a4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -752,9 +752,7 @@ pub(crate) fn new_status_output( /// 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()); + let lines: Vec> = vec![Line::from(""), Line::from(message).dim()]; TranscriptOnlyHistoryCell { lines } }