Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Bolin
5c3db6d578 Extract tool config into codex-tools 2026-03-31 17:38:37 -07:00
18 changed files with 843 additions and 1099 deletions

4
codex-rs/Cargo.lock generated
View File

@@ -2617,11 +2617,15 @@ version = "0.0.0"
dependencies = [
"codex-app-server-protocol",
"codex-code-mode",
"codex-features",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-pty",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"tracing",
]
[[package]]

View File

@@ -309,8 +309,7 @@ where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Vec<T>>::deserialize(deserializer)
.map(std::option::Option::unwrap_or_default)
Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
}
#[derive(Clone, Debug, Deserialize)]

View File

@@ -944,7 +944,9 @@ impl TurnContext {
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
Self {
sub_id: self.sub_id.clone(),
@@ -1396,13 +1398,15 @@ impl Session {
windows_sandbox_level: session_configuration.windows_sandbox_level,
})
.with_unified_exec_shell_mode_for_session(
user_shell,
crate::tools::spec::tool_user_shell_type(user_shell),
shell_zsh_path,
main_execve_wrapper_exe,
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&per_turn_config.agent_roles,
));
let cwd = session_configuration.cwd.clone();
@@ -5468,13 +5472,15 @@ async fn spawn_review_thread(
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
})
.with_unified_exec_shell_mode_for_session(
sess.services.user_shell.as_ref(),
crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()),
sess.services.shell_zsh_path.as_ref(),
sess.services.main_execve_wrapper_exe.as_ref(),
)
.with_web_search_config(/*web_search_config*/ None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
let review_prompt = resolved.prompt.clone();
let provider = parent_turn_context.provider.clone();

View File

@@ -1,28 +1,2 @@
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
pub(crate) fn can_request_original_image_detail(
features: &Features,
model_info: &ModelInfo,
) -> bool {
model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal)
}
pub(crate) fn normalize_output_image_detail(
features: &Features,
model_info: &ModelInfo,
detail: Option<ImageDetail>,
) -> Option<ImageDetail> {
match detail {
Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => {
Some(ImageDetail::Original)
}
Some(ImageDetail::Original) | Some(_) | None => None,
}
}
#[cfg(test)]
#[path = "original_image_detail_tests.rs"]
mod tests;
pub(crate) use codex_tools::can_request_original_image_detail;
pub(crate) use codex_tools::normalize_output_image_detail;

View File

@@ -54,105 +54,56 @@ impl ToolHandler for Handler {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
prompt: prompt.clone(),
model: args
.model_fallback_list
.as_ref()
.and_then(|list| list.first())
.map(|candidate| candidate.model.clone())
.unwrap_or_else(|| args.model.clone().unwrap_or_default()),
reasoning_effort: args
.model_fallback_list
.as_ref()
.and_then(|list| list.first())
.and_then(|candidate| candidate.reasoning_effort)
.unwrap_or_else(|| args.reasoning_effort.unwrap_or_default()),
model: args.model.clone().unwrap_or_default(),
reasoning_effort: args.reasoning_effort.unwrap_or_default(),
}
.into(),
)
.await;
let config =
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
let mut candidates_to_try = collect_spawn_agent_model_candidates(
args.model_fallback_list.as_ref(),
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
);
if candidates_to_try.is_empty() {
candidates_to_try.push(SpawnAgentModelCandidate {
model: None,
reasoning_effort: None,
});
}
)
.await?;
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
let mut spawn_result = None;
for (idx, candidate) in candidates_to_try.iter().enumerate() {
let mut candidate_config = config.clone();
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut candidate_config,
candidate.model.as_deref(),
candidate.reasoning_effort,
let result = session
.services
.agent_control
.spawn_agent_with_metadata(
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
/*task_name*/ None,
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
},
)
.await?;
apply_role_to_config(&mut candidate_config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut candidate_config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut candidate_config, child_depth);
let attempt_result = session
.services
.agent_control
.spawn_agent_with_metadata(
candidate_config,
input_items.clone(),
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
/*task_name*/ None,
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
},
)
.await;
match attempt_result {
Ok(spawned_agent) => {
if spawn_should_retry_on_async_quota_exhaustion(
spawned_agent.status.clone(),
spawned_agent.thread_id,
&session.services.agent_control,
)
.await
&& idx + 1 < candidates_to_try.len()
{
continue;
}
spawn_result = Some(spawned_agent);
break;
}
Err(err) => {
if spawn_should_retry_on_quota_exhaustion(&err)
&& idx + 1 < candidates_to_try.len()
{
continue;
}
return Err(collab_spawn_error(err));
}
}
}
let Some(spawned_agent) = spawn_result else {
return Err(FunctionCallError::RespondToModel(
"No spawn attempts were executed".to_string(),
));
.await
.map_err(collab_spawn_error);
let (new_thread_id, new_agent_metadata, status) = match &result {
Ok(spawned_agent) => (
Some(spawned_agent.thread_id),
Some(spawned_agent.metadata.clone()),
spawned_agent.status.clone(),
),
Err(_) => (None, None, AgentStatus::NotFound),
};
let new_thread_id = Some(spawned_agent.thread_id);
let new_agent_metadata = Some(spawned_agent.metadata.clone());
let status = spawned_agent.status.clone();
let agent_snapshot = match new_thread_id {
Some(thread_id) => {
session
@@ -203,7 +154,7 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let new_thread_id = spawned_agent.thread_id;
let new_thread_id = result?.thread_id;
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
turn.session_telemetry.counter(
"codex.multi_agent.spawn",
@@ -224,7 +175,6 @@ struct SpawnAgentArgs {
items: Option<Vec<UserInput>>,
agent_type: Option<String>,
model: Option<String>,
model_fallback_list: Option<Vec<SpawnAgentModelFallbackCandidate>>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,

View File

@@ -1,5 +1,4 @@
use crate::agent::AgentStatus;
use crate::agent::status::is_final;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
@@ -22,12 +21,9 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use tokio::time::Duration;
use tokio::time::timeout;
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
@@ -75,96 +71,6 @@ where
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SpawnAgentModelCandidate {
pub(crate) model: Option<String>,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct SpawnAgentModelFallbackCandidate {
pub(crate) model: String,
#[serde(default)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
pub(crate) fn collect_spawn_agent_model_candidates(
model_fallback_list: Option<&Vec<SpawnAgentModelFallbackCandidate>>,
requested_model: Option<&str>,
requested_reasoning_effort: Option<ReasoningEffort>,
) -> Vec<SpawnAgentModelCandidate> {
if let Some(model_fallback_list) = model_fallback_list {
return model_fallback_list
.iter()
.map(|candidate| SpawnAgentModelCandidate {
model: Some(candidate.model.clone()),
reasoning_effort: candidate.reasoning_effort,
})
.collect();
}
let mut candidates = Vec::new();
if requested_model.is_some() || requested_reasoning_effort.is_some() {
candidates.push(SpawnAgentModelCandidate {
model: requested_model.map(ToString::to_string),
reasoning_effort: requested_reasoning_effort,
});
}
candidates
}
pub(crate) fn spawn_should_retry_on_quota_exhaustion(error: &CodexErr) -> bool {
matches!(
error,
CodexErr::QuotaExceeded | CodexErr::UsageLimitReached(_)
)
}
pub(crate) async fn spawn_should_retry_on_async_quota_exhaustion(
thread_status: AgentStatus,
thread_id: ThreadId,
agent_control: &crate::agent::control::AgentControl,
) -> bool {
if is_final(&thread_status) && spawn_should_retry_on_quota_exhaustion_status(&thread_status) {
return true;
}
let Ok(mut status_rx) = agent_control.subscribe_status(thread_id).await else {
return false;
};
let mut status = status_rx.borrow_and_update().clone();
if is_final(&status) && spawn_should_retry_on_quota_exhaustion_status(&status) {
return true;
}
loop {
if timeout(Duration::from_millis(250), status_rx.changed())
.await
.is_err()
{
break;
}
status = status_rx.borrow().clone();
if is_final(&status) {
return spawn_should_retry_on_quota_exhaustion_status(&status);
}
}
false
}
fn spawn_should_retry_on_quota_exhaustion_status(status: &AgentStatus) -> bool {
match status {
AgentStatus::Errored(message) => {
let message = message.to_lowercase();
message.contains("insufficient_quota")
|| message.contains("usage limit")
|| message.contains("quota")
}
AgentStatus::NotFound => false,
_ => false,
}
}
pub(crate) fn build_wait_agent_statuses(
statuses: &HashMap<ThreadId, AgentStatus>,
receiver_agents: &[CollabAgentRef],
@@ -457,111 +363,3 @@ fn validate_spawn_agent_reasoning_effort(
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::UsageLimitReachedError;
use crate::protocol::AgentStatus;
#[test]
fn collect_spawn_agent_model_candidates_prefers_fallback_list() {
let candidates = collect_spawn_agent_model_candidates(
Some(&vec![
SpawnAgentModelFallbackCandidate {
model: "fallback-a".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
},
SpawnAgentModelFallbackCandidate {
model: "fallback-b".to_string(),
reasoning_effort: Some(ReasoningEffort::Minimal),
},
]),
Some("legacy-model"),
Some(ReasoningEffort::Low),
);
assert_eq!(
candidates,
vec![
SpawnAgentModelCandidate {
model: Some("fallback-a".to_string()),
reasoning_effort: Some(ReasoningEffort::High),
},
SpawnAgentModelCandidate {
model: Some("fallback-b".to_string()),
reasoning_effort: Some(ReasoningEffort::Minimal),
},
]
);
}
#[test]
fn collect_spawn_agent_model_candidates_falls_back_to_legacy_args() {
let candidates = collect_spawn_agent_model_candidates(
/*model_fallback_list*/ None,
Some("legacy-model"),
Some(ReasoningEffort::Minimal),
);
assert_eq!(
candidates,
vec![SpawnAgentModelCandidate {
model: Some("legacy-model".to_string()),
reasoning_effort: Some(ReasoningEffort::Minimal),
}]
);
}
#[test]
fn collect_spawn_agent_model_candidates_empty_when_no_model_is_set() {
let candidates = collect_spawn_agent_model_candidates(
/*model_fallback_list*/ None, /*requested_model*/ None,
/*requested_reasoning_effort*/ None,
);
assert_eq!(candidates, Vec::new());
}
#[test]
fn spawn_should_retry_on_quota_exhaustion_checks_expected_error_variants() {
assert!(spawn_should_retry_on_quota_exhaustion(
&CodexErr::QuotaExceeded
));
assert!(spawn_should_retry_on_quota_exhaustion(
&CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type: None,
resets_at: None,
rate_limits: None,
promo_message: None,
})
));
assert!(!spawn_should_retry_on_quota_exhaustion(
&CodexErr::UnsupportedOperation("thread manager dropped".to_string())
));
}
#[test]
fn collab_spawn_error_handles_thread_manager_drop() {
assert_eq!(
collab_spawn_error(CodexErr::UnsupportedOperation(
"thread manager dropped".to_string()
)),
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
);
}
#[test]
fn build_wait_agent_statuses_includes_extras_in_sorted_order() {
let receiver_agents = vec![];
let mut statuses = HashMap::new();
let thread_a = ThreadId::new();
let thread_b = ThreadId::new();
statuses.insert(thread_b, AgentStatus::Completed(Some("done".to_string())));
statuses.insert(thread_a, AgentStatus::Completed(Some("done".to_string())));
let entries = build_wait_agent_statuses(&statuses, &receiver_agents);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].thread_id, thread_a);
assert_eq!(entries[1].thread_id, thread_b);
}
}

View File

@@ -33,13 +33,14 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
let fork_mode = args.fork_mode()?;
let role_name = args
.agent_type
.as_deref()
.map(str::trim)
.filter(|role| !role.is_empty());
let initial_operation = parse_collab_input(args.message, args.items)?;
let initial_operation = parse_collab_input(/*message*/ None, Some(args.items))?;
let prompt = render_input_preview(&initial_operation);
let session_source = turn.session_source.clone();
@@ -57,24 +58,27 @@ impl ToolHandler for Handler {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
prompt: prompt.clone(),
model: args
.model_fallback_list
.as_ref()
.and_then(|list| list.first())
.map(|candidate| candidate.model.clone())
.unwrap_or_else(|| args.model.clone().unwrap_or_default()),
reasoning_effort: args
.model_fallback_list
.as_ref()
.and_then(|list| list.first())
.and_then(|candidate| candidate.reasoning_effort)
.unwrap_or_else(|| args.reasoning_effort.unwrap_or_default()),
model: args.model.clone().unwrap_or_default(),
reasoning_effort: args.reasoning_effort.unwrap_or_default(),
}
.into(),
)
.await;
let config =
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
)
.await?;
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
let spawn_source = thread_spawn_source(
session.conversation_id,
@@ -83,100 +87,47 @@ impl ToolHandler for Handler {
role_name,
Some(args.task_name.clone()),
)?;
let initial_agent_op = match (spawn_source.get_agent_path(), initial_operation) {
(Some(recipient), Op::UserInput { items, .. })
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. })) =>
{
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
recipient,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
),
}
}
(_, initial_operation) => initial_operation,
};
let mut candidates_to_try = collect_spawn_agent_model_candidates(
args.model_fallback_list.as_ref(),
args.model.as_deref(),
args.reasoning_effort,
);
if candidates_to_try.is_empty() {
candidates_to_try.push(SpawnAgentModelCandidate {
model: None,
reasoning_effort: None,
});
}
let mut spawn_result = None;
for (idx, candidate) in candidates_to_try.iter().enumerate() {
let mut candidate_config = config.clone();
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut candidate_config,
candidate.model.as_deref(),
candidate.reasoning_effort,
let result = session
.services
.agent_control
.spawn_agent_with_metadata(
config,
match (spawn_source.get_agent_path(), initial_operation) {
(Some(recipient), Op::UserInput { items, .. })
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. })) =>
{
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
recipient,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
),
}
}
(_, initial_operation) => initial_operation,
},
Some(spawn_source),
SpawnAgentOptions {
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
fork_mode,
},
)
.await?;
apply_role_to_config(&mut candidate_config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut candidate_config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut candidate_config, child_depth);
let attempt_result = session
.services
.agent_control
.spawn_agent_with_metadata(
candidate_config,
initial_agent_op.clone(),
Some(spawn_source.clone()),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
},
)
.await;
match attempt_result {
Ok(spawned_agent) => {
if spawn_should_retry_on_async_quota_exhaustion(
spawned_agent.status.clone(),
spawned_agent.thread_id,
&session.services.agent_control,
)
.await
&& idx + 1 < candidates_to_try.len()
{
continue;
}
spawn_result = Some(spawned_agent);
break;
}
Err(err) => {
if spawn_should_retry_on_quota_exhaustion(&err)
&& idx + 1 < candidates_to_try.len()
{
continue;
}
return Err(collab_spawn_error(err));
}
}
}
let Some(spawned_agent) = spawn_result else {
return Err(FunctionCallError::RespondToModel(
"No spawn attempts were executed".to_string(),
));
.await
.map_err(collab_spawn_error);
let (new_thread_id, new_agent_metadata, status) = match &result {
Ok(spawned_agent) => (
Some(spawned_agent.thread_id),
Some(spawned_agent.metadata.clone()),
spawned_agent.status.clone(),
),
Err(_) => (None, None, AgentStatus::NotFound),
};
let new_thread_id = Some(spawned_agent.thread_id);
let new_agent_metadata = Some(spawned_agent.metadata.clone());
let status = spawned_agent.status.clone();
let agent_snapshot = match new_thread_id {
Some(thread_id) => {
session
@@ -227,6 +178,7 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let _ = result?;
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
turn.session_telemetry.counter(
"codex.multi_agent.spawn",
@@ -248,16 +200,54 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SpawnAgentArgs {
message: Option<String>,
items: Option<Vec<UserInput>>,
items: Vec<UserInput>,
task_name: String,
agent_type: Option<String>,
model: Option<String>,
model_fallback_list: Option<Vec<SpawnAgentModelFallbackCandidate>>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,
fork_turns: Option<String>,
fork_context: Option<bool>,
}
impl SpawnAgentArgs {
fn fork_mode(&self) -> Result<Option<SpawnAgentForkMode>, FunctionCallError> {
if self.fork_context.is_some() {
return Err(FunctionCallError::RespondToModel(
"fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(),
));
}
let Some(fork_turns) = self
.fork_turns
.as_deref()
.map(str::trim)
.filter(|fork_turns| !fork_turns.is_empty())
else {
return Ok(None);
};
if fork_turns.eq_ignore_ascii_case("none") {
return Ok(None);
}
if fork_turns.eq_ignore_ascii_case("all") {
return Ok(Some(SpawnAgentForkMode::FullHistory));
}
let last_n_turns = fork_turns.parse::<usize>().map_err(|_| {
FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string(),
)
})?;
if last_n_turns == 0 {
return Err(FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string(),
));
}
Ok(Some(SpawnAgentForkMode::LastNTurns(last_n_turns)))
}
}
#[derive(Debug, Serialize)]

View File

@@ -1,8 +1,6 @@
use crate::client_common::tools::ToolSpec;
use crate::config::AgentRoleConfig;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use crate::original_image_detail::can_request_original_image_detail;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
@@ -21,21 +19,11 @@ use crate::tools::handlers::request_permissions_tool_description;
use crate::tools::handlers::request_user_input_tool_description;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::registry::tool_handler_key;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::CommandToolOptions;
use codex_tools::DiscoverableTool;
use codex_tools::DiscoverableToolType;
@@ -43,6 +31,7 @@ use codex_tools::ShellToolOptions;
use codex_tools::SpawnAgentToolOptions;
use codex_tools::ToolSearchAppInfo;
use codex_tools::ToolSuggestEntry;
use codex_tools::ToolUserShellType;
use codex_tools::ViewImageToolOptions;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::augment_tool_spec_for_code_mode;
@@ -80,282 +69,38 @@ use codex_tools::create_write_stdin_tool;
use codex_tools::dynamic_tool_to_responses_api_tool;
use codex_tools::mcp_tool_to_responses_api_tool;
use codex_tools::tool_spec_to_code_mode_tool_definition;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
pub type JsonSchema = codex_tools::JsonSchema;
pub use codex_tools::ShellCommandBackendConfig;
pub use codex_tools::ToolsConfig;
pub use codex_tools::ToolsConfigParams;
pub use codex_tools::UnifiedExecShellMode;
pub use codex_tools::ZshForkConfig;
#[cfg(test)]
pub(crate) use codex_tools::mcp_call_tool_result_output_schema;
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ShellCommandBackendConfig {
Classic,
ZshFork,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum UnifiedExecShellMode {
Direct,
ZshFork(ZshForkConfig),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ZshForkConfig {
pub(crate) shell_zsh_path: AbsolutePathBuf,
pub(crate) main_execve_wrapper_exe: AbsolutePathBuf,
}
impl UnifiedExecShellMode {
pub fn for_session(
shell_command_backend: ShellCommandBackendConfig,
user_shell: &Shell,
shell_zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Self {
if cfg!(unix)
&& shell_command_backend == ShellCommandBackendConfig::ZshFork
&& matches!(user_shell.shell_type, ShellType::Zsh)
&& let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) =
(shell_zsh_path, main_execve_wrapper_exe)
&& let (Ok(shell_zsh_path), Ok(main_execve_wrapper_exe)) = (
AbsolutePathBuf::try_from(shell_zsh_path.as_path())
.inspect_err(|e| tracing::warn!("Failed to convert shell_zsh_path `{shell_zsh_path:?}`: {e:?}")),
AbsolutePathBuf::try_from(main_execve_wrapper_exe.as_path()).inspect_err(|e| {
tracing::warn!("Failed to convert main_execve_wrapper_exe `{main_execve_wrapper_exe:?}`: {e:?}")
}),
)
{
Self::ZshFork(ZshForkConfig {
shell_zsh_path,
main_execve_wrapper_exe,
})
} else {
Self::Direct
}
pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
match user_shell.shell_type {
ShellType::Zsh => ToolUserShellType::Zsh,
ShellType::Bash => ToolUserShellType::Bash,
ShellType::PowerShell => ToolUserShellType::PowerShell,
ShellType::Sh => ToolUserShellType::Sh,
ShellType::Cmd => ToolUserShellType::Cmd,
}
}
#[derive(Debug, Clone)]
pub(crate) struct ToolsConfig {
pub available_models: Vec<ModelPreset>,
pub shell_type: ConfigShellToolType,
shell_command_backend: ShellCommandBackendConfig,
pub unified_exec_shell_mode: UnifiedExecShellMode,
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub web_search_config: Option<WebSearchConfig>,
pub web_search_tool_type: WebSearchToolType,
pub image_gen_tool: bool,
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
pub search_tool: bool,
pub tool_suggest: bool,
pub exec_permission_approvals_enabled: bool,
pub request_permissions_tool_enabled: bool,
pub code_mode_enabled: bool,
pub code_mode_only_enabled: bool,
pub js_repl_enabled: bool,
pub js_repl_tools_only: bool,
pub can_request_original_image_detail: bool,
pub collab_tools: bool,
pub multi_agent_v2: bool,
pub request_user_input: bool,
pub default_mode_request_user_input: bool,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
pub agent_jobs_worker_tools: bool,
}
pub(crate) struct ToolsConfigParams<'a> {
pub(crate) model_info: &'a ModelInfo,
pub(crate) available_models: &'a Vec<ModelPreset>,
pub(crate) features: &'a Features,
pub(crate) web_search_mode: Option<WebSearchMode>,
pub(crate) session_source: SessionSource,
pub(crate) sandbox_policy: &'a SandboxPolicy,
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
}
fn unified_exec_allowed_in_environment(
is_windows: bool,
sandbox_policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
) -> bool {
!(is_windows
&& windows_sandbox_level != WindowsSandboxLevel::Disabled
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
))
}
impl ToolsConfig {
pub fn new(params: &ToolsConfigParams) -> Self {
let ToolsConfigParams {
model_info,
available_models: available_models_ref,
features,
web_search_mode,
session_source,
sandbox_policy,
windows_sandbox_level,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_code_mode = features.enabled(Feature::CodeMode);
let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
let include_collab_tools = features.enabled(Feature::Collab);
let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2);
let include_agent_jobs = features.enabled(Feature::SpawnCsv);
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool =
model_info.supports_search_tool && features.enabled(Feature::ToolSearch);
let include_tool_suggest = features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins);
let include_original_image_detail = can_request_original_image_detail(features, model_info);
let include_image_gen_tool =
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool);
let shell_command_backend =
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
ShellCommandBackendConfig::ZshFork
} else {
ShellCommandBackendConfig::Classic
};
let unified_exec_allowed = unified_exec_allowed_in_environment(
cfg!(target_os = "windows"),
sandbox_policy,
*windows_sandbox_level,
);
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::ShellZshFork) {
ConfigShellToolType::ShellCommand
} else if features.enabled(Feature::UnifiedExec) && unified_exec_allowed {
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
if codex_utils_pty::conpty_supported() {
ConfigShellToolType::UnifiedExec
} else {
ConfigShellToolType::ShellCommand
}
} else if model_info.shell_type == ConfigShellToolType::UnifiedExec && !unified_exec_allowed
{
ConfigShellToolType::ShellCommand
} else {
model_info.shell_type
};
let apply_patch_tool_type = match model_info.apply_patch_tool_type {
Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
None => {
if include_apply_patch_tool {
Some(ApplyPatchToolType::Freeform)
} else {
None
}
}
};
let agent_jobs_worker_tools = include_agent_jobs
&& matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::Other(label))
if label.starts_with("agent_job:")
);
Self {
available_models: available_models_ref.to_vec(),
shell_type,
shell_command_backend,
unified_exec_shell_mode: UnifiedExecShellMode::Direct,
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
web_search_config: None,
web_search_tool_type: model_info.web_search_tool_type,
image_gen_tool: include_image_gen_tool,
agent_roles: BTreeMap::new(),
search_tool: include_search_tool,
tool_suggest: include_tool_suggest,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
code_mode_enabled: include_code_mode,
code_mode_only_enabled: include_code_mode_only,
js_repl_enabled: include_js_repl,
js_repl_tools_only: include_js_repl_tools_only,
can_request_original_image_detail: include_original_image_detail,
collab_tools: include_collab_tools,
multi_agent_v2: include_multi_agent_v2,
request_user_input: include_request_user_input,
default_mode_request_user_input: include_default_mode_request_user_input,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
agent_jobs_worker_tools,
}
fn agent_type_description(config: &ToolsConfig) -> String {
if config.agent_type_description.is_empty() {
crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new())
} else {
config.agent_type_description.clone()
}
pub fn with_agent_roles(mut self, agent_roles: BTreeMap<String, AgentRoleConfig>) -> Self {
self.agent_roles = agent_roles;
self
}
pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self {
self.allow_login_shell = allow_login_shell;
self
}
pub fn with_unified_exec_shell_mode(
mut self,
unified_exec_shell_mode: UnifiedExecShellMode,
) -> Self {
self.unified_exec_shell_mode = unified_exec_shell_mode;
self
}
pub fn with_unified_exec_shell_mode_for_session(
mut self,
user_shell: &Shell,
shell_zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Self {
self.unified_exec_shell_mode = UnifiedExecShellMode::for_session(
self.shell_command_backend,
user_shell,
shell_zsh_path,
main_execve_wrapper_exe,
);
self
}
pub fn with_web_search_config(mut self, web_search_config: Option<WebSearchConfig>) -> Self {
self.web_search_config = web_search_config;
self
}
pub fn for_code_mode_nested_tools(&self) -> Self {
let mut nested = self.clone();
nested.code_mode_enabled = false;
nested.code_mode_only_enabled = false;
nested
}
}
fn supports_image_generation(model_info: &ModelInfo) -> bool {
model_info.input_modalities.contains(&InputModality::Image)
}
/// TODO(dylan): deprecate once we get rid of json tool
@@ -775,13 +520,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
if config.collab_tools {
if config.multi_agent_v2 {
let agent_type_description = agent_type_description(config);
push_tool_spec(
&mut builder,
create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description: crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
),
agent_type_description,
}),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
@@ -827,13 +571,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler("close_agent", Arc::new(CloseAgentHandlerV2));
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
} else {
let agent_type_description = agent_type_description(config);
push_tool_spec(
&mut builder,
create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description: crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
),
agent_type_description,
}),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,

View File

@@ -6,10 +6,18 @@ use crate::shell::ShellType;
use crate::tools::ToolRouter;
use crate::tools::router::ToolRouterParams;
use codex_app_server_protocol::AppInfo;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::AdditionalProperties;
use codex_tools::CommandToolOptions;
use codex_tools::ConfiguredToolSpec;
@@ -40,6 +48,7 @@ use codex_tools::mcp_tool_to_deferred_responses_api_tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
use super::*;
@@ -178,7 +187,7 @@ fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSp
fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions<'_> {
SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description: crate::agent::role::spawn_tool_spec::build(&config.agent_roles),
agent_type_description: agent_type_description(config),
}
}
@@ -248,30 +257,6 @@ fn model_info_from_models_json(slug: &str) -> ModelInfo {
with_config_overrides(model, &config)
}
#[test]
fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() {
assert!(!unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::new_read_only_policy(),
WindowsSandboxLevel::RestrictedToken,
));
assert!(!unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::new_workspace_write_policy(),
WindowsSandboxLevel::RestrictedToken,
));
assert!(unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::DangerFullAccess,
WindowsSandboxLevel::RestrictedToken,
));
assert!(unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::DangerFullAccess,
WindowsSandboxLevel::Disabled,
));
}
#[test]
fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
let mut model_info = model_info_from_models_json("gpt-5-codex");
@@ -1640,7 +1625,7 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
assert_eq!(
tools_config
.with_unified_exec_shell_mode_for_session(
&user_shell,
tool_user_shell_type(&user_shell),
Some(&PathBuf::from(if cfg!(windows) {
r"C:\opt\codex\zsh"
} else {

View File

@@ -18,7 +18,6 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::time::Duration;
use tokio::time::Instant;
@@ -37,10 +36,6 @@ const REQUESTED_MODEL: &str = "gpt-5.1";
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
const ROLE_MODEL: &str = "gpt-5.1-codex-max";
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
const FALLBACK_MODEL_A: &str = "gpt-5.1";
const FALLBACK_REASONING_EFFORT_A: ReasoningEffort = ReasoningEffort::Low;
const FALLBACK_MODEL_B: &str = "gpt-5.2";
const FALLBACK_REASONING_EFFORT_B: ReasoningEffort = ReasoningEffort::Medium;
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
let is_zstd = req
@@ -62,57 +57,6 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool {
.is_some_and(|body| body.contains(text))
}
fn request_uses_model_and_effort(
req: &wiremock::Request,
model: &str,
reasoning_effort: &str,
) -> bool {
let is_zstd = req
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
let bytes = if is_zstd {
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
} else {
Some(req.body.clone())
};
bytes
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
.is_some_and(|body| {
body.get("model").and_then(Value::as_str) == Some(model)
&& body
.get("reasoning")
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
== Some(reasoning_effort)
})
}
fn request_uses_model(req: &wiremock::Request, model: &str) -> bool {
let is_zstd = req
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
let bytes = if is_zstd {
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
} else {
Some(req.body.clone())
};
bytes
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
.is_some_and(|body| body.get("model").and_then(Value::as_str) == Some(model))
}
fn has_subagent_notification(req: &ResponsesRequest) -> bool {
req.message_input_texts("user")
.iter()
@@ -536,184 +480,6 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawn_agent_model_fallback_list_retries_after_quota_exhaustion() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
"model_fallback_list": [
{
"model": FALLBACK_MODEL_A,
"reasoning_effort": FALLBACK_REASONING_EFFORT_A,
},
{
"model": FALLBACK_MODEL_B,
"reasoning_effort": FALLBACK_REASONING_EFFORT_B,
}
]
}))?;
mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
sse(vec![
ev_response_created("resp-turn1-1"),
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
ev_completed("resp-turn1-1"),
]),
)
.await;
let quota_child_attempt = mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, CHILD_PROMPT)
&& request_uses_model_and_effort(req, FALLBACK_MODEL_A, "low")
&& !body_contains(req, SPAWN_CALL_ID)
},
sse(vec![
ev_response_created("resp-child-quota"),
json!({
"type": "response.failed",
"response": {
"id": "resp-child-quota",
"error": {
"code": "insufficient_quota",
"message": "You exceeded your current quota, please check your plan and billing details."
}
}
}),
]),
)
.await;
let fallback_child_attempt = mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, CHILD_PROMPT)
&& request_uses_model(req, FALLBACK_MODEL_B)
&& !body_contains(req, SPAWN_CALL_ID)
},
sse(vec![
ev_response_created("resp-child-fallback"),
ev_assistant_message("msg-child-fallback", "child done"),
ev_completed("resp-child-fallback"),
]),
)
.await;
let _turn1_followup = mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-turn1-2"),
ev_assistant_message("msg-turn1-2", "parent done"),
ev_completed("resp-turn1-2"),
]),
)
.await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
config.model = Some(INHERITED_MODEL.to_string());
config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT);
});
let test = builder.build(&server).await?;
test.submit_turn(TURN_1_PROMPT).await?;
let quota_requests = quota_child_attempt
.requests()
.into_iter()
.filter(|request| {
request.body_json().get("model").and_then(Value::as_str) == Some(FALLBACK_MODEL_A)
})
.collect::<Vec<_>>();
assert!(!quota_requests.is_empty());
for quota_request in &quota_requests {
let body = quota_request.body_json();
assert_eq!(
body.get("model").and_then(Value::as_str),
Some(FALLBACK_MODEL_A)
);
assert_eq!(
body.get("reasoning")
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str),
Some("low")
);
}
let fallback_requests = fallback_child_attempt
.requests()
.into_iter()
.filter(|request| {
request.body_json().get("model").and_then(Value::as_str) == Some(FALLBACK_MODEL_B)
})
.collect::<Vec<_>>();
assert!(!fallback_requests.is_empty());
for fallback_request in &fallback_requests {
let fallback_body = fallback_request.body_json();
assert_eq!(
fallback_body.get("model").and_then(Value::as_str),
Some(FALLBACK_MODEL_B)
);
if let Some(effort) = fallback_body
.get("reasoning")
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
{
assert_eq!(effort, "medium");
}
}
let deadline = Instant::now() + Duration::from_secs(2);
let child_snapshot = loop {
let spawned_ids = test
.thread_manager
.list_thread_ids()
.await
.into_iter()
.filter(|id| *id != test.session_configured.session_id)
.collect::<Vec<_>>();
let mut matching_snapshot = None;
for thread_id in spawned_ids {
let snapshot = test
.thread_manager
.get_thread(thread_id)
.await?
.config_snapshot()
.await;
if snapshot.model == FALLBACK_MODEL_B
&& snapshot.reasoning_effort == Some(FALLBACK_REASONING_EFFORT_B)
{
matching_snapshot = Some(snapshot);
break;
}
}
if let Some(snapshot) = matching_snapshot {
break snapshot;
}
if Instant::now() >= deadline {
anyhow::bail!("timed out waiting for fallback child snapshot");
}
sleep(Duration::from_millis(10)).await;
};
assert_eq!(child_snapshot.model, FALLBACK_MODEL_B);
assert_eq!(
child_snapshot.reasoning_effort,
Some(FALLBACK_REASONING_EFFORT_B)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -10,7 +10,10 @@ workspace = true
[dependencies]
codex-app-server-protocol = { workspace = true }
codex-code-mode = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",
"macros",
@@ -19,6 +22,7 @@ rmcp = { workspace = true, default-features = false, features = [
] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -23,7 +23,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description =
"Returns the spawned agent id plus the user-facing nickname when available.";
let properties = spawn_agent_common_properties(&options.agent_type_description);
let properties = spawn_agent_common_properties_v1(&options.agent_type_description);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
@@ -45,7 +45,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available.";
let mut properties = spawn_agent_common_properties(&options.agent_type_description);
let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
properties.insert(
"task_name".to_string(),
JsonSchema::String {
@@ -66,7 +66,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task_name".to_string()]),
required: Some(vec!["task_name".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v2()),
@@ -128,20 +128,11 @@ pub fn create_send_message_tool() -> ToolSpec {
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
description: "Add a message to an existing agent without triggering a new turn. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
@@ -544,28 +535,7 @@ fn create_collab_input_items_schema() -> JsonSchema {
}
}
fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
let model_fallback_item_properties = BTreeMap::from([
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Model to try. Must be a model slug from the current model picker list."
.to_string(),
),
},
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for this candidate. Replaces the inherited reasoning effort."
.to_string(),
),
},
),
]);
fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
@@ -602,15 +572,40 @@ fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap<Strin
},
),
(
"model_fallback_list".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: model_fallback_item_properties,
required: Some(vec!["model".to_string()]),
additional_properties: Some(false.into()),
}),
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Ordered model candidates for fallback retries. Each entry may include an optional reasoning effort."
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
),
])
}
fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
("items".to_string(), create_collab_input_items_schema()),
(
"agent_type".to_string(),
JsonSchema::String {
description: Some(agent_type_description.to_string()),
},
),
(
"fork_turns".to_string(),
JsonSchema::String {
description: Some(
"Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
.to_string(),
),
},
),
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
),
},
@@ -730,31 +725,19 @@ fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema
}
fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first."
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
),
]);
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
)]);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
required: None,
additional_properties: Some(false.into()),
}
}

View File

@@ -56,34 +56,20 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::String {
description: Some("role help".to_string()),
})
);
assert_eq!(required, Some(vec!["task_name".to_string()]));
let Some(JsonSchema::Array { items, .. }) = properties.get("model_fallback_list") else {
panic!("spawn_agent v2 should define model_fallback_list as an array of objects");
};
let JsonSchema::Object {
properties: model_fallback_item_properties,
required: Some(model_fallback_item_required),
..
} = items.as_ref()
else {
panic!("spawn_agent v2 model_fallback_list items should be objects");
};
assert_eq!(
model_fallback_item_properties.get("model"),
Some(&JsonSchema::String {
description: Some(
"Model to try. Must be a model slug from the current model picker list."
.to_string(),
),
})
required,
Some(vec!["task_name".to_string(), "items".to_string()])
);
assert_eq!(model_fallback_item_required, &vec!["model".to_string()]);
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
json!(["agent_id", "task_name", "nickname"])
@@ -91,22 +77,21 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
}
#[test]
fn spawn_agent_tool_v1_includes_model_fallback_list() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) =
create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: &[model_preset("visible", /*show_in_picker*/ true)],
agent_type_description: "role help".to_string(),
})
else {
fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: &[],
agent_type_description: "role help".to_string(),
});
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("spawn_agent should use object params");
};
let Some(JsonSchema::Array { .. }) = properties.get("model_fallback_list") else {
panic!("model_fallback_list should be an array");
};
assert!(properties.contains_key("model_fallback_list"));
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
}
#[test]
@@ -129,6 +114,7 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("items"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("message"));
assert_eq!(
required,
@@ -141,7 +127,7 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
}
#[test]
fn wait_agent_tool_v2_uses_task_targets_and_summary_output() {
fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
@@ -154,17 +140,17 @@ fn wait_agent_tool_v2_uses_task_targets_and_summary_output() {
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("wait_agent should use object params");
};
let Some(JsonSchema::Array {
description: Some(description),
..
}) = properties.get("targets")
else {
panic!("wait_agent should define targets array");
};
assert!(description.contains("canonical task names"));
assert!(!properties.contains_key("targets"));
assert!(properties.contains_key("timeout_ms"));
assert_eq!(required, None);
assert_eq!(
output_schema.expect("wait output schema")["properties"]["message"]["description"],
json!("Brief wait summary without the agent's final content.")

View File

@@ -0,0 +1,25 @@
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
pub fn can_request_original_image_detail(features: &Features, model_info: &ModelInfo) -> bool {
model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal)
}
pub fn normalize_output_image_detail(
features: &Features,
model_info: &ModelInfo,
detail: Option<ImageDetail>,
) -> Option<ImageDetail> {
match detail {
Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => {
Some(ImageDetail::Original)
}
Some(ImageDetail::Original) | Some(_) | None => None,
}
}
#[cfg(test)]
#[path = "image_detail_tests.rs"]
mod tests;

View File

@@ -1,16 +1,49 @@
use super::*;
use crate::config::test_config;
use crate::models_manager::manager::ModelsManager;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
use pretty_assertions::assert_eq;
use serde_json::json;
fn model_info() -> ModelInfo {
serde_json::from_value(json!({
"slug": "test-model",
"display_name": "Test Model",
"description": null,
"supported_reasoning_levels": [],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base",
"model_messages": null,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": true,
"context_window": null,
"auto_compact_token_limit": null,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"],
"supports_search_tool": false
}))
.expect("deserialize test model")
}
#[test]
fn image_detail_original_feature_enables_explicit_original_without_force() {
let config = test_config();
let mut model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
model_info.supports_image_detail_original = true;
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::ImageDetailOriginal);
@@ -27,10 +60,7 @@ fn image_detail_original_feature_enables_explicit_original_without_force() {
#[test]
fn explicit_original_is_dropped_without_feature_or_model_support() {
let config = test_config();
let mut model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
model_info.supports_image_detail_original = true;
let mut model_info = model_info();
let features = Features::with_defaults();
assert_eq!(
@@ -49,10 +79,7 @@ fn explicit_original_is_dropped_without_feature_or_model_support() {
#[test]
fn unsupported_non_original_detail_is_dropped() {
let config = test_config();
let mut model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
model_info.supports_image_detail_original = true;
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::ImageDetailOriginal);

View File

@@ -5,6 +5,7 @@ mod agent_job_tool;
mod agent_tool;
mod code_mode;
mod dynamic_tool;
mod image_detail;
mod js_repl_tool;
mod json_schema;
mod local_tool;
@@ -12,6 +13,7 @@ mod mcp_resource_tool;
mod mcp_tool;
mod request_user_input_tool;
mod responses_api;
mod tool_config;
mod tool_definition;
mod tool_discovery;
mod tool_spec;
@@ -38,6 +40,8 @@ pub use code_mode::create_code_mode_tool;
pub use code_mode::create_wait_tool;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
pub use dynamic_tool::parse_dynamic_tool;
pub use image_detail::can_request_original_image_detail;
pub use image_detail::normalize_output_image_detail;
pub use js_repl_tool::create_js_repl_reset_tool;
pub use js_repl_tool::create_js_repl_tool;
pub use json_schema::AdditionalProperties;
@@ -66,6 +70,12 @@ pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_config::ShellCommandBackendConfig;
pub use tool_config::ToolUserShellType;
pub use tool_config::ToolsConfig;
pub use tool_config::ToolsConfigParams;
pub use tool_config::UnifiedExecShellMode;
pub use tool_config::ZshForkConfig;
pub use tool_definition::ToolDefinition;
pub use tool_discovery::DiscoverablePluginInfo;
pub use tool_discovery::DiscoverableTool;

View File

@@ -0,0 +1,294 @@
use crate::can_request_original_image_detail;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ShellCommandBackendConfig {
Classic,
ZshFork,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ToolUserShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum UnifiedExecShellMode {
Direct,
ZshFork(ZshForkConfig),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ZshForkConfig {
pub shell_zsh_path: AbsolutePathBuf,
pub main_execve_wrapper_exe: AbsolutePathBuf,
}
impl UnifiedExecShellMode {
pub fn for_session(
shell_command_backend: ShellCommandBackendConfig,
user_shell_type: ToolUserShellType,
shell_zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Self {
if cfg!(unix)
&& shell_command_backend == ShellCommandBackendConfig::ZshFork
&& matches!(user_shell_type, ToolUserShellType::Zsh)
&& let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) =
(shell_zsh_path, main_execve_wrapper_exe)
&& let (Ok(shell_zsh_path), Ok(main_execve_wrapper_exe)) = (
AbsolutePathBuf::try_from(shell_zsh_path.as_path()).inspect_err(|err| {
tracing::warn!(
"Failed to convert shell_zsh_path `{shell_zsh_path:?}`: {err:?}"
)
}),
AbsolutePathBuf::try_from(main_execve_wrapper_exe.as_path()).inspect_err(
|err| {
tracing::warn!(
"Failed to convert main_execve_wrapper_exe `{main_execve_wrapper_exe:?}`: {err:?}"
)
},
),
)
{
Self::ZshFork(ZshForkConfig {
shell_zsh_path,
main_execve_wrapper_exe,
})
} else {
Self::Direct
}
}
}
#[derive(Debug, Clone)]
pub struct ToolsConfig {
pub available_models: Vec<ModelPreset>,
pub shell_type: ConfigShellToolType,
pub shell_command_backend: ShellCommandBackendConfig,
pub unified_exec_shell_mode: UnifiedExecShellMode,
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub web_search_config: Option<WebSearchConfig>,
pub web_search_tool_type: WebSearchToolType,
pub image_gen_tool: bool,
pub search_tool: bool,
pub tool_suggest: bool,
pub exec_permission_approvals_enabled: bool,
pub request_permissions_tool_enabled: bool,
pub code_mode_enabled: bool,
pub code_mode_only_enabled: bool,
pub js_repl_enabled: bool,
pub js_repl_tools_only: bool,
pub can_request_original_image_detail: bool,
pub collab_tools: bool,
pub multi_agent_v2: bool,
pub request_user_input: bool,
pub default_mode_request_user_input: bool,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
pub agent_jobs_worker_tools: bool,
pub agent_type_description: String,
}
pub struct ToolsConfigParams<'a> {
pub model_info: &'a ModelInfo,
pub available_models: &'a [ModelPreset],
pub features: &'a Features,
pub web_search_mode: Option<WebSearchMode>,
pub session_source: SessionSource,
pub sandbox_policy: &'a SandboxPolicy,
pub windows_sandbox_level: WindowsSandboxLevel,
}
impl ToolsConfig {
pub fn new(params: &ToolsConfigParams<'_>) -> Self {
let ToolsConfigParams {
model_info,
available_models,
features,
web_search_mode,
session_source,
sandbox_policy,
windows_sandbox_level,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_code_mode = features.enabled(Feature::CodeMode);
let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
let include_collab_tools = features.enabled(Feature::Collab);
let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2);
let include_agent_jobs = features.enabled(Feature::SpawnCsv);
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool =
model_info.supports_search_tool && features.enabled(Feature::ToolSearch);
let include_tool_suggest = features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins);
let include_original_image_detail = can_request_original_image_detail(features, model_info);
let include_image_gen_tool =
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool);
let shell_command_backend =
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
ShellCommandBackendConfig::ZshFork
} else {
ShellCommandBackendConfig::Classic
};
let unified_exec_allowed = unified_exec_allowed_in_environment(
cfg!(target_os = "windows"),
sandbox_policy,
*windows_sandbox_level,
);
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::ShellZshFork) {
ConfigShellToolType::ShellCommand
} else if features.enabled(Feature::UnifiedExec) && unified_exec_allowed {
if codex_utils_pty::conpty_supported() {
ConfigShellToolType::UnifiedExec
} else {
ConfigShellToolType::ShellCommand
}
} else if model_info.shell_type == ConfigShellToolType::UnifiedExec && !unified_exec_allowed
{
ConfigShellToolType::ShellCommand
} else {
model_info.shell_type
};
let apply_patch_tool_type = match model_info.apply_patch_tool_type {
Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
None => include_apply_patch_tool.then_some(ApplyPatchToolType::Freeform),
};
let agent_jobs_worker_tools = include_agent_jobs
&& matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::Other(label))
if label.starts_with("agent_job:")
);
Self {
available_models: available_models.to_vec(),
shell_type,
shell_command_backend,
unified_exec_shell_mode: UnifiedExecShellMode::Direct,
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
web_search_config: None,
web_search_tool_type: model_info.web_search_tool_type,
image_gen_tool: include_image_gen_tool,
search_tool: include_search_tool,
tool_suggest: include_tool_suggest,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
code_mode_enabled: include_code_mode,
code_mode_only_enabled: include_code_mode_only,
js_repl_enabled: include_js_repl,
js_repl_tools_only: include_js_repl_tools_only,
can_request_original_image_detail: include_original_image_detail,
collab_tools: include_collab_tools,
multi_agent_v2: include_multi_agent_v2,
request_user_input: include_request_user_input,
default_mode_request_user_input: include_default_mode_request_user_input,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
agent_jobs_worker_tools,
agent_type_description: String::new(),
}
}
pub fn with_agent_type_description(mut self, agent_type_description: String) -> Self {
self.agent_type_description = agent_type_description;
self
}
pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self {
self.allow_login_shell = allow_login_shell;
self
}
pub fn with_unified_exec_shell_mode(
mut self,
unified_exec_shell_mode: UnifiedExecShellMode,
) -> Self {
self.unified_exec_shell_mode = unified_exec_shell_mode;
self
}
pub fn with_unified_exec_shell_mode_for_session(
mut self,
user_shell_type: ToolUserShellType,
shell_zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Self {
self.unified_exec_shell_mode = UnifiedExecShellMode::for_session(
self.shell_command_backend,
user_shell_type,
shell_zsh_path,
main_execve_wrapper_exe,
);
self
}
pub fn with_web_search_config(mut self, web_search_config: Option<WebSearchConfig>) -> Self {
self.web_search_config = web_search_config;
self
}
pub fn for_code_mode_nested_tools(&self) -> Self {
let mut nested = self.clone();
nested.code_mode_enabled = false;
nested.code_mode_only_enabled = false;
nested
}
}
fn supports_image_generation(model_info: &ModelInfo) -> bool {
model_info.input_modalities.contains(&InputModality::Image)
}
fn unified_exec_allowed_in_environment(
is_windows: bool,
sandbox_policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
) -> bool {
!(is_windows
&& windows_sandbox_level != WindowsSandboxLevel::Disabled
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
))
}
#[cfg(test)]
#[path = "tool_config_tests.rs"]
mod tests;

View File

@@ -0,0 +1,200 @@
use super::*;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
fn model_info() -> ModelInfo {
serde_json::from_value(json!({
"slug": "test-model",
"display_name": "Test Model",
"description": null,
"supported_reasoning_levels": [],
"shell_type": "unified_exec",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base",
"model_messages": null,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": null,
"auto_compact_token_limit": null,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"],
"supports_search_tool": false
}))
.expect("deserialize test model")
}
#[test]
fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() {
assert!(!unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::new_read_only_policy(),
WindowsSandboxLevel::RestrictedToken,
));
assert!(!unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::new_workspace_write_policy(),
WindowsSandboxLevel::RestrictedToken,
));
assert!(unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::DangerFullAccess,
WindowsSandboxLevel::RestrictedToken,
));
assert!(unified_exec_allowed_in_environment(
/*is_windows*/ true,
&SandboxPolicy::DangerFullAccess,
WindowsSandboxLevel::Disabled,
));
}
#[test]
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::ShellZshFork);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
assert_eq!(
tools_config.shell_command_backend,
ShellCommandBackendConfig::ZshFork
);
assert_eq!(
tools_config.unified_exec_shell_mode,
UnifiedExecShellMode::Direct
);
assert_eq!(
tools_config
.with_unified_exec_shell_mode_for_session(
ToolUserShellType::Zsh,
Some(&PathBuf::from(if cfg!(windows) {
r"C:\opt\codex\zsh"
} else {
"/opt/codex/zsh"
})),
Some(&PathBuf::from(if cfg!(windows) {
r"C:\opt\codex\codex-execve-wrapper"
} else {
"/opt/codex/codex-execve-wrapper"
})),
)
.unified_exec_shell_mode,
if cfg!(unix) {
UnifiedExecShellMode::ZshFork(ZshForkConfig {
shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(),
main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(
"/opt/codex/codex-execve-wrapper",
)
.unwrap(),
})
} else {
UnifiedExecShellMode::Direct
}
);
}
#[test]
fn subagents_disable_request_user_input_and_agent_jobs_workers_opt_in_by_label() {
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::SpawnCsv);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::SubAgent(SubAgentSource::Other(
"agent_job:test".to_string(),
)),
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
assert!(!tools_config.request_user_input);
assert!(!tools_config.default_mode_request_user_input);
assert!(tools_config.agent_jobs_tools);
assert!(tools_config.agent_jobs_worker_tools);
}
#[test]
fn image_generation_requires_feature_and_supported_model() {
let supported_model_info = model_info();
let mut unsupported_model_info = supported_model_info.clone();
unsupported_model_info.input_modalities = vec![InputModality::Text];
let default_features = Features::with_defaults();
let mut image_generation_features = default_features.clone();
image_generation_features.enable(Feature::ImageGeneration);
let available_models = Vec::new();
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &supported_model_info,
available_models: &available_models,
features: &default_features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let supported_tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &supported_model_info,
available_models: &available_models,
features: &image_generation_features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let unsupported_tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &unsupported_model_info,
available_models: &available_models,
features: &image_generation_features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
assert!(!default_tools_config.image_gen_tool);
assert!(supported_tools_config.image_gen_tool);
assert!(!unsupported_tools_config.image_gen_tool);
}