diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dbccbd863e..302f68e87f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -724,6 +724,7 @@ dependencies = [ "env-flags", "eventsource-stream", "futures", + "include_dir", "landlock", "libc", "maplit", @@ -2400,6 +2401,25 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indenter" version = "0.3.3" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2f2fa7cbad..f38320f662 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -24,6 +24,7 @@ dirs = "6" env-flags = "0.1.1" eventsource-stream = "0.2.3" futures = "0.3" +include_dir = "0.7" libc = "0.2.175" mcp-types = { path = "../mcp-types" } mime_guess = "2.0" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e175f55094..b54c3c059c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -77,7 +77,6 @@ use crate::protocol::AgentReasoningRawContentEvent; use crate::protocol::AgentReasoningSectionBreakEvent; use crate::protocol::ApplyPatchApprovalRequestEvent; use crate::protocol::AskForApproval; -use crate::protocol::BackgroundEventEvent; use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; @@ -269,6 +268,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>, @@ -514,19 +522,39 @@ impl Session { config.include_apply_patch_tool, config.tools_web_search_request, config.use_experimental_streamable_shell_tool, + config.include_subagent_tool, ), user_instructions, base_instructions, approval_policy, sandbox_policy, shell_environment_policy: config.shell_environment_policy.clone(), - cwd, + cwd: cwd.clone(), disable_response_storage, }; + // 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(); + let sess = Arc::new(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), @@ -594,6 +622,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() + } + pub async fn request_command_approval( &self, sub_id: String, @@ -839,18 +877,8 @@ impl Session { result } - /// Helper that emits a BackgroundEvent with the given message. This keeps - /// the call‑sites terse so adding more diagnostics does not clutter the - /// core agent logic. - async fn notify_background_event(&self, sub_id: &str, message: impl Into) { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: message.into(), - }), - }; - let _ = self.tx_event.send(event).await; - } + /// Background events are disabled; this is a no-op to preserve call-sites. + async fn notify_background_event(&self, _sub_id: &str, _message: impl Into) {} async fn notify_stream_error(&self, sub_id: &str, message: impl Into) { let event = Event { @@ -1100,6 +1128,7 @@ async fn submission_loop( config.include_apply_patch_tool, config.tools_web_search_request, config.use_experimental_streamable_shell_tool, + config.include_subagent_tool, ); let new_turn_context = TurnContext { @@ -1180,6 +1209,7 @@ async fn submission_loop( config.include_apply_patch_tool, config.tools_web_search_request, config.use_experimental_streamable_shell_tool, + config.include_subagent_tool, ), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -2096,6 +2126,143 @@ 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 agent_name = args.name.clone(); + let result = crate::subagents::runner::run( + sess, + turn_context, + &sess.subagents_registry, + crate::subagents::runner::RunSubagentArgs { + name: agent_name.clone(), + input: args.input, + context: args.context, + }, + &sub_id, + ) + .await; + + match result { + Ok(message) => { + // If this subagent declares an output schema, validate against it and + // return the JSON body unmodified. Otherwise fallback to envelope for text. + if let Some(def) = sess.subagents_registry.get(&agent_name) + && let Some(schema) = def.output_schema() + { + match serde_json::from_str::(&message) { + Ok(val) => { + if let Err(err) = + crate::subagents::runner::validate_json_against_schema( + &val, schema, + ) + { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "subagent structured output validation failed: {err}" + ), + success: Some(false), + }, + }; + } + // Valid JSON: pass through as-is + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: message, + success: Some(true), + }, + } + } + Err(e) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "subagent must return valid JSON per its schema: {e}" + ), + success: Some(false), + }, + }, + } + } else { + // No schema: best-effort envelope + let json_payload = match serde_json::from_str::(&message) + { + Ok(val) => serde_json::json!({ + "subagent": agent_name, + "output": val, + }), + Err(_) => serde_json::json!({ + "subagent": agent_name, + "output": { "type": "text", "message": message }, + }), + }; + let content = serde_json::to_string(&json_payload).unwrap_or(message); + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content, + success: Some(true), + }, + } + } + } + Err(e) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("subagent failed: {e}"), + success: Some(false), + }, + }, + } + } + "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), + }, + } + } EXEC_COMMAND_TOOL_NAME => { // TODO(mbolin): Sandbox check. let exec_params = match serde_json::from_str::(&arguments) { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 98a8fde135..f6679ff096 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -169,6 +169,8 @@ 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, pub tools_web_search_request: bool, /// The value for the `originator` header included with Responses API requests. @@ -485,6 +487,8 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// Include the `subagent.run` tool allowing the model to invoke configured subagents. + pub include_subagent_tool: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -586,6 +590,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, pub tools_web_search_request: Option, @@ -613,6 +618,7 @@ impl Config { base_instructions, include_plan_tool, include_apply_patch_tool, + include_subagent_tool, disable_response_storage, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, @@ -778,6 +784,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), tools_web_search_request, responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), @@ -1148,6 +1159,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, tools_web_search_request: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, @@ -1204,6 +1216,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, tools_web_search_request: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, @@ -1275,6 +1288,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + include_subagent_tool: false, tools_web_search_request: 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 ae18332087..4c9cf6f42f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -63,3 +63,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 516a984453..dad647917e 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -67,6 +67,7 @@ pub struct ToolsConfig { pub plan_tool: bool, pub apply_patch_tool_type: Option, pub web_search_request: bool, + pub subagent_tool: bool, } impl ToolsConfig { @@ -78,6 +79,7 @@ impl ToolsConfig { include_apply_patch_tool: bool, include_web_search_request: bool, use_streamable_shell_tool: bool, + include_subagent_tool: bool, ) -> Self { let mut shell_type = if use_streamable_shell_tool { ConfigShellToolType::StreamableShell @@ -109,6 +111,7 @@ impl ToolsConfig { plan_tool: include_plan_tool, apply_patch_tool_type, web_search_request: include_web_search_request, + subagent_tool: include_subagent_tool, } } } @@ -515,6 +518,12 @@ pub(crate) fn get_openai_tools( tools.push(PLAN_TOOL.clone()); } + 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(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { @@ -541,6 +550,7 @@ pub(crate) fn get_openai_tools( } } + tracing::trace!("Tools: {:?}", tools); tools } @@ -588,6 +598,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -605,6 +616,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -622,6 +634,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools( &config, @@ -721,6 +734,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools( @@ -779,6 +793,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools( @@ -832,6 +847,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools( @@ -888,6 +904,7 @@ mod tests { false, true, /*use_experimental_streamable_shell_tool*/ false, + /*include_subagent_tool*/ false, ); let tools = get_openai_tools( diff --git a/codex-rs/core/src/subagents/default_agents.rs b/codex-rs/core/src/subagents/default_agents.rs new file mode 100644 index 0000000000..3f4cf19654 --- /dev/null +++ b/codex-rs/core/src/subagents/default_agents.rs @@ -0,0 +1,71 @@ +use super::definition::SubagentDefinition; +use include_dir::Dir; +use include_dir::include_dir; + +/// Load embedded default subagents shipped with the binary. +/// +/// Project (~/.codex/agents) and user (.codex/agents) definitions override these. +pub(crate) fn embedded_defs() -> Vec { + static DEFAULTS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/subagents/defaults"); + + let mut defs: Vec = Vec::new(); + + for file in DEFAULTS_DIR.files() { + if file + .path() + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("json")) + .unwrap_or(false) + { + match file.contents_utf8() { + Some(contents) => match SubagentDefinition::from_json_str(contents) { + Ok(def) => defs.push(def), + Err(e) => { + tracing::warn!( + "failed to parse embedded default subagent '{}': {}", + file.path().display(), + e + ); + } + }, + None => { + tracing::warn!( + "embedded defaults file is not valid UTF-8: {}", + file.path().display() + ); + } + } + } + } + + defs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_defaults_are_valid_subagents() { + // Ensure we can load all embedded defaults and that the list is non-empty + let defs = embedded_defs(); + assert!( + !defs.is_empty(), + "expected at least one default subagent in src/subagents/defaults" + ); + + // Basic sanity checks on each definition + for def in defs { + assert!( + !def.name.trim().is_empty(), + "subagent name must not be empty" + ); + assert!( + !def.instructions.trim().is_empty(), + "subagent '{}' must have instructions", + def.name + ); + } + } +} diff --git a/codex-rs/core/src/subagents/defaults/hello.json b/codex-rs/core/src/subagents/defaults/hello.json new file mode 100644 index 0000000000..2e831b137d --- /dev/null +++ b/codex-rs/core/src/subagents/defaults/hello.json @@ -0,0 +1,8 @@ +{ + "name": "hello", + "description": "Built-in test subagent that replies with a greeting", + "instructions": "Reply with exactly this text and nothing else: Hello from subagent", + "tools": [], + "reasoning_effort": "minimal" +} + diff --git a/codex-rs/core/src/subagents/definition.rs b/codex-rs/core/src/subagents/definition.rs new file mode 100644 index 0000000000..aea54f8569 --- /dev/null +++ b/codex-rs/core/src/subagents/definition.rs @@ -0,0 +1,55 @@ +use crate::openai_tools::JsonSchema; +use codex_protocol::config_types::ReasoningEffort; +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 + + /// Optional structured output schema. When set, the subagent must return a + /// single JSON value that validates against this schema. The schema will be + /// embedded into the subagent's instructions so the model can adhere to it. + #[serde(default)] + output_schema: Option, + + /// Optional model override for this subagent. When not provided, inherits + /// the parent session's configured model. + #[serde(default)] + pub model: Option, + + /// Optional reasoning effort override for this subagent. When not provided, + /// inherits the parent session's configured reasoning effort. + #[serde(default)] + pub reasoning_effort: Option, +} + +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()), + ) + }) + } + + pub(crate) fn output_schema(&self) -> Option<&JsonSchema> { + self.output_schema.as_ref() + } + +} diff --git a/codex-rs/core/src/subagents/mod.rs b/codex-rs/core/src/subagents/mod.rs new file mode 100644 index 0000000000..eb9d8fd652 --- /dev/null +++ b/codex-rs/core/src/subagents/mod.rs @@ -0,0 +1,8 @@ +mod default_agents; +pub mod definition; +pub mod registry; +pub mod runner; +pub mod tool; + +pub(crate) use tool::SUBAGENT_LIST_TOOL; +pub(crate) use 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..3c0cb9956b --- /dev/null +++ b/codex-rs/core/src/subagents/registry.rs @@ -0,0 +1,82 @@ +use super::default_agents; +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(); + + // Start with embedded defaults (lowest precedence). + for def in default_agents::embedded_defs() { + map.insert(def.name.clone(), def); + } + + // 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); + } + + // No ad-hoc fallback here; embedded defaults already include "hello". + + 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..f9a56539c3 --- /dev/null +++ b/codex-rs/core/src/subagents/runner.rs @@ -0,0 +1,228 @@ +use crate::codex::Codex; +use crate::error::Result as CodexResult; + +use super::definition::SubagentDefinition; +use super::registry::SubagentRegistry; +use crate::openai_tools::JsonSchema; +use serde_json::Value as JsonValue; + +/// 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(); + // Compose base instructions with structured output schema instructions, if present. + let base_instructions = if let Some(schema) = def.output_schema() { + let schema_json = serde_json::to_string_pretty(schema).unwrap_or_else(|_| "{}".to_string()); + format!( + "{instructions}\n\nOutput format requirements:\n- Reply with a single JSON value that strictly matches the following JSON Schema.\n- Do not include any commentary, markdown, or extra text.\n- Do not include trailing explanations.\n\nSchema:\n{schema_json}\n", + instructions = def.instructions, + schema_json = schema_json + ) + } else { + def.instructions.clone() + }; + nested_cfg.base_instructions = Some(base_instructions); + nested_cfg.user_instructions = None; + // Apply subagent-specific overrides for model and reasoning effort. + if let Some(model) = &def.model { + nested_cfg.model = model.clone(); + } + if let Some(re) = def.reasoning_effort { + nested_cfg.model_reasoning_effort = re; + } + 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; + } +} + +/// Minimal validator for our limited JsonSchema subset used across the codebase. +pub(crate) fn validate_json_against_schema( + value: &JsonValue, + schema: &JsonSchema, +) -> Result<(), String> { + match schema { + JsonSchema::Boolean { .. } => { + if value.is_boolean() { + Ok(()) + } else { + Err("expected boolean".to_string()) + } + } + JsonSchema::String { .. } => { + if value.is_string() { + Ok(()) + } else { + Err("expected string".to_string()) + } + } + JsonSchema::Number { .. } => { + if value.is_number() { + Ok(()) + } else { + Err("expected number".to_string()) + } + } + JsonSchema::Array { items, .. } => { + if let JsonValue::Array(arr) = value { + for (i, v) in arr.iter().enumerate() { + validate_json_against_schema(v, items) + .map_err(|e| format!("array[{i}]: {e}"))?; + } + Ok(()) + } else { + Err("expected array".to_string()) + } + } + JsonSchema::Object { + properties, + required, + additional_properties, + } => { + let obj = match value.as_object() { + Some(o) => o, + None => return Err("expected object".to_string()), + }; + // Check required + if let Some(req) = required { + for key in req { + if !obj.contains_key(key) { + return Err(format!("missing required property: {key}")); + } + } + } + // Validate each present property + for (k, v) in obj.iter() { + if let Some(child_schema) = properties.get(k) { + validate_json_against_schema(v, child_schema) + .map_err(|e| format!("property '{k}': {e}"))?; + } else if matches!(additional_properties, Some(false)) { + return Err(format!("unexpected property: {k}")); + } + } + Ok(()) + } + } +} + +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..56e61a4ac3 --- /dev/null +++ b/codex-rs/core/src/subagents/tool.rs @@ -0,0 +1,56 @@ +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/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index cfdba98461..fe9336cb62 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -169,6 +169,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 3de95291d1..654935383b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -148,6 +148,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any base_instructions: None, include_plan_tool: None, include_apply_patch_tool: None, + include_subagent_tool: None, disable_response_storage: oss.then_some(true), show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 97a3602d70..27009f26e9 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, tools_web_search_request: None, diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 69f07ff223..0bd60a8dd1 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, tools_web_search_request: None, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 8480e29c53..e159705d94 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -277,6 +277,9 @@ async fn run_codex_tool_session_inner( | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::ConversationHistory(_) + | EventMsg::SubagentBegin(_) + | EventMsg::SubagentForwarded(_) + | EventMsg::SubagentEnd(_) | EventMsg::ShutdownComplete => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 71c1538197..875e05788c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -480,6 +480,13 @@ pub enum EventMsg { ShutdownComplete, ConversationHistory(ConversationHistoryResponseEvent), + + /// 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. @@ -591,6 +598,28 @@ impl fmt::Display for FinalOutput { } } +#[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, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageEvent { pub message: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1fb5c6dfe5..ed69b927c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -906,6 +906,25 @@ impl ChatWidget { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { 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. + self.add_to_history(history_cell::new_log_line(format!( + "subagent forwarded: {:?}", + ev.event + ))); + } + 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(ev) => { // Forward to App so it can process backtrack flows. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 0b2af7a100..6b1c1616f5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1152,6 +1152,12 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { Line::from(invocation_spans) } +/// Simple one-line log entry (dim) to surface traces and diagnostics in the transcript. +pub(crate) fn new_log_line(message: String) -> TranscriptOnlyHistoryCell { + let lines: Vec> = vec![Line::from(""), Line::from(message).dim()]; + TranscriptOnlyHistoryCell { lines } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cac5b8acf9..102415eea1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -126,6 +126,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),