Compare commits

...

7 Commits

Author SHA1 Message Date
Ahmed Ibrahim
75543d66f3 Merge branch 'main' into subagents-new 2025-08-24 16:15:34 -07:00
Ahmed Ibrahim
e36c08b36c clippy 2025-08-24 10:11:29 -07:00
Ahmed Ibrahim
9e96800adf clippy 2025-08-24 00:07:32 -07:00
Ahmed Ibrahim
a8d258f7f0 not optional 2025-08-23 23:59:38 -07:00
Ahmed Ibrahim
a79c71ec00 remove from session 2025-08-23 23:59:38 -07:00
Ahmed Ibrahim
113c87e8ae agents 2025-08-23 23:59:38 -07:00
Ahmed Ibrahim
bfe99e4396 subagents 2025-08-23 23:57:47 -07:00
24 changed files with 845 additions and 14 deletions

20
codex-rs/Cargo.lock generated
View File

@@ -726,6 +726,7 @@ dependencies = [
"env-flags",
"eventsource-stream",
"futures",
"include_dir",
"landlock",
"libc",
"maplit",
@@ -2402,6 +2403,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"

View File

@@ -25,6 +25,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"

View File

@@ -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;
@@ -297,6 +296,9 @@ pub(crate) struct TurnContext {
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) disable_response_storage: bool,
pub(crate) tools_config: ToolsConfig,
pub(crate) subagents_registry: crate::subagents::registry::SubagentRegistry,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) base_config: Arc<Config>,
}
impl TurnContext {
@@ -504,6 +506,22 @@ 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();
let turn_context = TurnContext {
client,
tools_config: ToolsConfig::new(
@@ -514,15 +532,20 @@ 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,
subagents_registry,
auth_manager: auth_manager.clone(),
base_config: config.clone(),
};
let sess = Arc::new(Session {
session_id,
tx_event: tx_event.clone(),
@@ -839,18 +862,8 @@ impl Session {
result
}
/// Helper that emits a BackgroundEvent with the given message. This keeps
/// the callsites terse so adding more diagnostics does not clutter the
/// core agent logic.
async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
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<String>) {}
async fn notify_stream_error(&self, sub_id: &str, message: impl Into<String>) {
let event = Event {
@@ -1100,6 +1113,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 {
@@ -1112,6 +1126,9 @@ async fn submission_loop(
shell_environment_policy: prev.shell_environment_policy.clone(),
cwd: new_cwd.clone(),
disable_response_storage: prev.disable_response_storage,
subagents_registry: prev.subagents_registry.clone(),
auth_manager: prev.auth_manager.clone(),
base_config: prev.base_config.clone(),
};
// Install the new persistent context for subsequent tasks/turns.
@@ -1180,6 +1197,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(),
@@ -1188,6 +1206,9 @@ async fn submission_loop(
shell_environment_policy: turn_context.shell_environment_policy.clone(),
cwd,
disable_response_storage: turn_context.disable_response_storage,
subagents_registry: turn_context.subagents_registry.clone(),
auth_manager: turn_context.auth_manager.clone(),
base_config: turn_context.base_config.clone(),
};
// TODO: record the new environment context in the conversation history
// no current task, spawn a new one with the perturn context
@@ -2096,6 +2117,131 @@ 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<String>,
}
let args = match serde_json::from_str::<Args>(&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,
crate::subagents::runner::RunSubagentArgs {
name: agent_name.clone(),
input: args.input,
context: args.context,
},
&sub_id,
)
.await;
match result {
Ok(message) => {
// Validate against the subagent's output schema and return
// the JSON body unmodified.
if let Some(def) = turn_context.subagents_registry.get(&agent_name) {
let schema = def.output_schema();
match serde_json::from_str::<serde_json::Value>(&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 {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!(
"subagent not found when validating output: {}",
agent_name
),
success: Some(false),
},
}
}
}
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 turn_context.subagents_registry.all_names() {
if let Some(def) = turn_context.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::<ExecCommandParams>(&arguments) {

View File

@@ -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<ToolsToml>,
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
pub include_subagent_tool: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -586,6 +590,7 @@ pub struct ConfigOverrides {
pub base_instructions: Option<String>,
pub include_plan_tool: Option<bool>,
pub include_apply_patch_tool: Option<bool>,
pub include_subagent_tool: Option<bool>,
pub disable_response_storage: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
@@ -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,

View File

@@ -21,4 +21,6 @@ pub struct ConfigProfile {
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
pub experimental_instructions_file: Option<PathBuf>,
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
pub include_subagent_tool: Option<bool>,
}

View File

@@ -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;

View File

@@ -67,8 +67,11 @@ pub struct ToolsConfig {
pub plan_tool: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
pub subagent_tool: bool,
}
// TODO: have an enum for available tools
#[allow(clippy::too_many_arguments)]
impl ToolsConfig {
pub fn new(
model_family: &ModelFamily,
@@ -78,6 +81,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 +113,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 +520,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 +552,7 @@ pub(crate) fn get_openai_tools(
}
}
tracing::trace!("Tools: {:?}", tools);
tools
}
@@ -588,6 +600,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 +618,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 +636,7 @@ mod tests {
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
/*include_subagent_tool*/ false,
);
let tools = get_openai_tools(
&config,
@@ -721,6 +736,7 @@ mod tests {
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
/*include_subagent_tool*/ false,
);
let tools = get_openai_tools(
@@ -779,6 +795,7 @@ mod tests {
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
/*include_subagent_tool*/ false,
);
let tools = get_openai_tools(
@@ -832,6 +849,7 @@ mod tests {
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
/*include_subagent_tool*/ false,
);
let tools = get_openai_tools(
@@ -888,6 +906,7 @@ mod tests {
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
/*include_subagent_tool*/ false,
);
let tools = get_openai_tools(

View File

@@ -0,0 +1,75 @@
use super::definition::SubagentDefinition;
use super::definition::SubagentSource;
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<SubagentDefinition> {
static DEFAULTS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/subagents/defaults");
let mut defs: Vec<SubagentDefinition> = 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(mut def) => {
def.source = SubagentSource::EmbeddedDefault;
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
);
}
}
}

View File

@@ -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, as a JSON string: Hello from subagent",
"output_schema": { "type": "string" },
"tools": [],
"reasoning_effort": "minimal"
}

View File

@@ -0,0 +1,65 @@
use crate::openai_tools::JsonSchema;
use codex_protocol::config_types::ReasoningEffort;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SubagentSource {
#[default]
EmbeddedDefault,
User,
Project,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubagentDefinition {
pub name: String,
pub description: String,
/// Base instructions for this subagent.
pub instructions: String,
// TODO: add allowed tools. we inherit the parent agent's tools for now.
// TODO: add cwd and approval policies.
/// Structured output schema. 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.
output_schema: JsonSchema,
/// Optional model override for this subagent. When not provided, inherits
/// the parent session's configured model.
#[serde(default)]
pub model: Option<String>,
/// Optional reasoning effort override for this subagent. When not provided,
/// inherits the parent session's configured reasoning effort.
#[serde(default)]
pub reasoning_effort: Option<ReasoningEffort>,
/// Where this definition was loaded from; used for precedence rules and
/// behavior differences (e.g., instruction composition).
/// Not serialized; defaults to EmbeddedDefault.
#[serde(skip)]
pub(crate) source: SubagentSource,
}
impl SubagentDefinition {
pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str::<Self>(s)
}
pub fn from_file(path: &Path) -> std::io::Result<Self> {
let contents = fs::read_to_string(path)?;
// Surface JSON parsing error with file context
serde_json::from_str::<Self>(&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) -> &JsonSchema {
&self.output_schema
}
}

View File

@@ -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;

View File

@@ -0,0 +1,88 @@
use super::default_agents;
use super::definition::SubagentDefinition;
use super::definition::SubagentSource;
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<PathBuf>,
/// Directory under CODEX_HOME (~/.codex/agents).
user_dir: Option<PathBuf>,
/// Merged map: project definitions override user ones.
map: HashMap<String, SubagentDefinition>,
}
impl SubagentRegistry {
pub fn new(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> 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<String, SubagentDefinition> = 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, SubagentSource::User);
}
// Then load project definitions which override on conflicts
if let Some(dir) = &self.project_dir {
Self::load_from_dir_into(dir, &mut map, SubagentSource::Project);
}
// 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<String> {
self.map.keys().cloned().collect()
}
fn load_from_dir_into(
dir: &Path,
out: &mut HashMap<String, SubagentDefinition>,
source: SubagentSource,
) {
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(mut def) => {
def.source = source;
out.insert(def.name.clone(), def);
}
Err(e) => {
tracing::warn!("Failed to load subagent from {}: {}", path.display(), e);
}
}
}
}
}
}

View File

@@ -0,0 +1,252 @@
use crate::codex::Codex;
use crate::error::Result as CodexResult;
use super::definition::SubagentDefinition;
use super::definition::SubagentSource;
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<String>,
}
/// Build the effective base instructions for a subagent run.
///
/// For user- and project-scoped subagents, we append their instructions to the
/// parent session's base instructions. For embedded defaults, we use only the
/// subagent's instructions. We always augment the subagent instructions with
/// strict JSON output requirements based on its schema.
fn compose_base_instructions_for_subagent(
def: &SubagentDefinition,
parent_base_instructions: Option<&str>,
) -> String {
// Start with the subagent's own instructions, optionally augmented with
// structured output requirements.
let schema_json =
serde_json::to_string_pretty(def.output_schema()).unwrap_or_else(|_| "{}".to_string());
let child_instructions = format!(
"{}\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{}\n",
def.instructions, schema_json
);
match def.source {
SubagentSource::User | SubagentSource::Project => match parent_base_instructions {
Some(parent) if !parent.trim().is_empty() => {
format!("{parent}\n\n{child}", child = child_instructions)
}
_ => child_instructions,
},
SubagentSource::EmbeddedDefault => child_instructions,
}
}
/// 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,
args: RunSubagentArgs,
_parent_sub_id: &str,
) -> CodexResult<String> {
let def: &SubagentDefinition =
turn_context
.subagents_registry
.get(&args.name)
.ok_or_else(|| {
crate::error::CodexErr::Stream(format!("unknown subagent: {}", args.name), None)
})?;
let mut nested_cfg = (*turn_context.base_config).clone();
let base_instructions =
compose_base_instructions_for_subagent(def, turn_context.base_instructions.as_deref());
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, turn_context.auth_manager.clone(), 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<String> = 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<String>,
) {
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;
}

View File

@@ -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<OpenAiTool> = 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<OpenAiTool> = 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),
},
})
});

View File

@@ -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}");

View File

@@ -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

View File

@@ -148,6 +148,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubagentForwardedEvent {
pub subagent_id: String,
pub name: String,
pub event: Box<EventMsg>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentMessageEvent {
pub message: String,

View File

@@ -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.

View File

@@ -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<Line<'static>> = vec![Line::from(""), Line::from(message).dim()];
TranscriptOnlyHistoryCell { lines }
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -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),