mirror of
https://github.com/openai/codex.git
synced 2026-03-25 17:46:50 +03:00
Compare commits
4 Commits
stack/util
...
aibrahim/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee03ffff16 | ||
|
|
bf7ef16afe | ||
|
|
4f76df40ca | ||
|
|
ede51f58ba |
@@ -2851,6 +2851,7 @@ impl Session {
|
||||
AskForApproval::Never => {
|
||||
return Some(RequestPermissionsResponse {
|
||||
permissions: PermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
});
|
||||
}
|
||||
AskForApproval::Reject(reject_config)
|
||||
@@ -2858,6 +2859,7 @@ impl Session {
|
||||
{
|
||||
return Some(RequestPermissionsResponse {
|
||||
permissions: PermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
});
|
||||
}
|
||||
AskForApproval::OnFailure
|
||||
@@ -6121,7 +6123,13 @@ async fn built_tools(
|
||||
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
|
||||
}
|
||||
|
||||
Ok(Arc::new(ToolRouter::from_config(
|
||||
let available_models = sess
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(crate::models_manager::manager::RefreshStrategy::Offline)
|
||||
.await;
|
||||
|
||||
Ok(Arc::new(ToolRouter::from_config_with_available_models(
|
||||
&turn_context.tools_config,
|
||||
has_mcp_servers.then(|| {
|
||||
mcp_tools
|
||||
@@ -6131,6 +6139,7 @@ async fn built_tools(
|
||||
}),
|
||||
app_tools,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
available_models.as_slice(),
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -2293,6 +2293,7 @@ async fn request_permissions_emits_event_when_reject_policy_allows_requests() {
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
scope: PermissionGrantScope::Turn,
|
||||
};
|
||||
|
||||
let handle = tokio::spawn({
|
||||
@@ -2377,6 +2378,7 @@ async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_reque
|
||||
Some(
|
||||
codex_protocol::request_permissions::RequestPermissionsResponse {
|
||||
permissions: codex_protocol::models::PermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -23,6 +23,9 @@ use async_trait::async_trait;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
@@ -112,6 +115,8 @@ mod spawn {
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
@@ -134,6 +139,8 @@ mod spawn {
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|role| !role.is_empty());
|
||||
let requested_model = args.model.clone();
|
||||
let requested_reasoning_effort = args.reasoning_effort;
|
||||
let input_items = parse_collab_input(args.message, args.items)?;
|
||||
let prompt = input_preview(&input_items);
|
||||
let session_source = turn.session_source.clone();
|
||||
@@ -160,6 +167,16 @@ mod spawn {
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
if role_name.is_none() {
|
||||
apply_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
requested_model.as_deref(),
|
||||
requested_reasoning_effort,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
|
||||
@@ -230,6 +247,122 @@ mod spawn {
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if let Some(model) = requested_model {
|
||||
let preset = visible_model_preset(session, model).await?;
|
||||
let reasoning_effort =
|
||||
requested_reasoning_effort.unwrap_or(preset.default_reasoning_effort);
|
||||
validate_reasoning_effort_for_preset(&preset, reasoning_effort)?;
|
||||
config.model_provider_id = turn.config.model_provider_id.clone();
|
||||
config.model_provider = turn.provider.clone();
|
||||
config.model = Some(preset.model);
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
let effective_model = config
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| turn.model_info.slug.clone());
|
||||
let model_info = if effective_model == turn.model_info.slug {
|
||||
turn.model_info.clone()
|
||||
} else {
|
||||
session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(effective_model.as_str(), config)
|
||||
.await
|
||||
};
|
||||
validate_reasoning_effort_for_model_info(&model_info, reasoning_effort)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn visible_model_preset(
|
||||
session: &Session,
|
||||
model: &str,
|
||||
) -> Result<ModelPreset, FunctionCallError> {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(crate::models_manager::manager::RefreshStrategy::Offline)
|
||||
.await;
|
||||
let visible_models = available_models
|
||||
.iter()
|
||||
.filter(|preset| preset.show_in_picker)
|
||||
.map(|preset| format!("`{}`", preset.model))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
available_models
|
||||
.into_iter()
|
||||
.find(|preset| preset.show_in_picker && preset.model == model)
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"spawn_agent model `{model}` is not available. Choose one of: {visible_models}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reasoning_effort_for_preset(
|
||||
preset: &ModelPreset,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let supported_reasoning_efforts = preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|effort| effort.effort)
|
||||
.collect::<Vec<_>>();
|
||||
validate_reasoning_effort(
|
||||
preset.model.as_str(),
|
||||
reasoning_effort,
|
||||
supported_reasoning_efforts.as_slice(),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_reasoning_effort_for_model_info(
|
||||
model_info: &ModelInfo,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let supported_reasoning_efforts = model_info
|
||||
.supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|effort| effort.effort)
|
||||
.collect::<Vec<_>>();
|
||||
validate_reasoning_effort(
|
||||
model_info.slug.as_str(),
|
||||
reasoning_effort,
|
||||
supported_reasoning_efforts.as_slice(),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_reasoning_effort(
|
||||
model: &str,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
supported_reasoning_efforts: &[ReasoningEffort],
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if supported_reasoning_efforts.contains(&reasoning_effort) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported_reasoning = supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|effort| format!("`{effort}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"spawn_agent reasoning_effort `{reasoning_effort}` is not supported for model `{model}`. Choose one of: {supported_reasoning}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
mod send_input {
|
||||
@@ -997,6 +1130,8 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -1038,6 +1173,17 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
async fn visible_models(session: &crate::codex::Session) -> Vec<ModelPreset> {
|
||||
session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(crate::models_manager::manager::RefreshStrategy::Offline)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|preset| preset.show_in_picker)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_rejects_non_function_payloads() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
@@ -1369,6 +1515,306 @@ mod tests {
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_applies_explicit_model_override() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.models_manager = manager.get_models_manager();
|
||||
let visible_models = visible_models(&session).await;
|
||||
let selected_model = visible_models
|
||||
.iter()
|
||||
.find(|preset| preset.model != turn.model_info.slug)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected visible model distinct from {}",
|
||||
turn.model_info.slug
|
||||
)
|
||||
})
|
||||
.clone();
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": selected_model.model,
|
||||
})),
|
||||
);
|
||||
let output = MultiAgentHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(content),
|
||||
..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
assert!(
|
||||
result
|
||||
.nickname
|
||||
.as_deref()
|
||||
.is_some_and(|nickname| !nickname.is_empty())
|
||||
);
|
||||
|
||||
let snapshot = manager
|
||||
.get_thread(agent_id(&result.agent_id).expect("agent_id should be valid"))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
assert_eq!(snapshot.model, selected_model.model);
|
||||
assert_eq!(
|
||||
snapshot.reasoning_effort,
|
||||
Some(selected_model.default_reasoning_effort)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_applies_explicit_reasoning_effort_override() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.models_manager = manager.get_models_manager();
|
||||
let turn = if turn.model_info.supported_reasoning_levels.is_empty() {
|
||||
let selected_model = visible_models(&session)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|preset| !preset.supported_reasoning_efforts.is_empty())
|
||||
.expect("expected a visible model with reasoning support");
|
||||
turn.with_model(selected_model.model, &session.services.models_manager)
|
||||
.await
|
||||
} else {
|
||||
turn
|
||||
};
|
||||
let inherited_model = turn.model_info.slug.clone();
|
||||
let selected_effort = turn
|
||||
.model_info
|
||||
.supported_reasoning_levels
|
||||
.iter()
|
||||
.find(|preset| Some(preset.effort) != turn.reasoning_effort)
|
||||
.or_else(|| turn.model_info.supported_reasoning_levels.first())
|
||||
.map(|preset| preset.effort)
|
||||
.expect("expected at least one supported reasoning level");
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"reasoning_effort": selected_effort,
|
||||
})),
|
||||
);
|
||||
let output = MultiAgentHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(content),
|
||||
..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
|
||||
let snapshot = manager
|
||||
.get_thread(agent_id(&result.agent_id).expect("agent_id should be valid"))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
assert_eq!(snapshot.model, inherited_model);
|
||||
assert_eq!(snapshot.reasoning_effort, Some(selected_effort));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_rejects_unknown_model_override() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.models_manager = manager.get_models_manager();
|
||||
let expected_visible_models = visible_models(&session)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|preset| format!("`{}`", preset.model))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "definitely-not-a-real-model",
|
||||
})),
|
||||
);
|
||||
let Err(err) = MultiAgentHandler.handle(invocation).await else {
|
||||
panic!("unknown model should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"spawn_agent model `definitely-not-a-real-model` is not available. Choose one of: {expected_visible_models}"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_rejects_unsupported_reasoning_effort_for_selected_model() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.models_manager = manager.get_models_manager();
|
||||
let candidate_efforts = [
|
||||
ReasoningEffort::None,
|
||||
ReasoningEffort::Minimal,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
];
|
||||
let selected_model = visible_models(&session)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|preset| {
|
||||
candidate_efforts.iter().any(|candidate| {
|
||||
!preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|effort| effort.effort == *candidate)
|
||||
})
|
||||
})
|
||||
.expect("expected a visible model without full reasoning coverage");
|
||||
let unsupported_effort = candidate_efforts
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
!selected_model
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|effort| effort.effort == *candidate)
|
||||
})
|
||||
.expect("expected unsupported effort");
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": selected_model.model,
|
||||
"reasoning_effort": unsupported_effort,
|
||||
})),
|
||||
);
|
||||
let Err(err) = MultiAgentHandler.handle(invocation).await else {
|
||||
panic!("unsupported reasoning effort should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"spawn_agent reasoning_effort `{unsupported_effort}` is not supported for model `{}`. Choose one of: {}",
|
||||
selected_model.model,
|
||||
selected_model
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|effort| format!("`{}`", effort.effort))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_role_model_beats_explicit_model_override() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let role_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.models_manager = manager.get_models_manager();
|
||||
let visible_models = visible_models(&session).await;
|
||||
let role_model = visible_models
|
||||
.first()
|
||||
.expect("expected at least one visible model")
|
||||
.clone();
|
||||
let selected_model = visible_models
|
||||
.iter()
|
||||
.find(|preset| preset.model != role_model.model)
|
||||
.expect("expected second visible model")
|
||||
.clone();
|
||||
let role_config_path = role_dir.path().join("role.toml");
|
||||
std::fs::write(
|
||||
&role_config_path,
|
||||
format!("model = \"{}\"\n", role_model.model),
|
||||
)
|
||||
.expect("write role config");
|
||||
let mut config = (*turn.config).clone();
|
||||
config.agent_roles.insert(
|
||||
"model-role".to_string(),
|
||||
crate::config::AgentRoleConfig {
|
||||
description: Some("Role that pins a model".to_string()),
|
||||
config_file: Some(role_config_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"agent_type": "model-role",
|
||||
"model": selected_model.model,
|
||||
})),
|
||||
);
|
||||
let output = MultiAgentHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(content),
|
||||
..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
|
||||
let snapshot = manager
|
||||
.get_thread(agent_id(&result.agent_id).expect("agent_id should be valid"))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
assert_eq!(snapshot.model, role_model.model);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_rejects_empty_message() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
|
||||
@@ -11,12 +11,14 @@ use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::build_specs;
|
||||
use crate::tools::spec::build_specs_with_available_models;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use rmcp::model::Tool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -49,6 +51,25 @@ impl ToolRouter {
|
||||
Self { registry, specs }
|
||||
}
|
||||
|
||||
pub fn from_config_with_available_models(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
available_models: &[ModelPreset],
|
||||
) -> Self {
|
||||
let builder = build_specs_with_available_models(
|
||||
config,
|
||||
mcp_tools,
|
||||
app_tools,
|
||||
dynamic_tools,
|
||||
available_models,
|
||||
);
|
||||
let (specs, registry) = builder.build();
|
||||
|
||||
Self { registry, specs }
|
||||
}
|
||||
|
||||
pub fn specs(&self) -> Vec<ToolSpec> {
|
||||
self.specs
|
||||
.iter()
|
||||
|
||||
@@ -27,6 +27,7 @@ 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::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -714,7 +715,7 @@ fn create_collab_input_items_schema() -> JsonSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
fn create_spawn_agent_tool(config: &ToolsConfig, available_models: &[ModelPreset]) -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"message".to_string(),
|
||||
@@ -734,6 +735,24 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
)),
|
||||
},
|
||||
),
|
||||
(
|
||||
"model".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional model slug for the spawned agent. Must match one of the visible models listed in this tool description."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"reasoning_effort".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional reasoning effort override for the spawned agent. If `model` is omitted, this is validated against the inherited parent model."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"fork_context".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
@@ -745,12 +764,14 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description: r#"Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
|
||||
let description = format!(
|
||||
r#"Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with the agent. This spawn_agent tool gives you access to smaller and more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below when using it.
|
||||
|
||||
### Available models
|
||||
{}
|
||||
|
||||
### When to delegate vs. do the subtask yourself
|
||||
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
|
||||
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed and can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
|
||||
- Use the smaller subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step.
|
||||
- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving.
|
||||
- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step.
|
||||
@@ -776,8 +797,13 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
- Run multiple independent information-seeking subtasks in parallel when you have distinct questions that can be answered independently.
|
||||
- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap.
|
||||
- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration.
|
||||
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
|
||||
.to_string(),
|
||||
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#,
|
||||
format_spawn_agent_model_catalog(available_models),
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -787,6 +813,43 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn format_spawn_agent_model_catalog(available_models: &[ModelPreset]) -> String {
|
||||
if available_models.is_empty() {
|
||||
return "No visible models are currently available.".to_string();
|
||||
}
|
||||
|
||||
available_models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.map(format_spawn_agent_model_entry)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn format_spawn_agent_model_entry(model: &ModelPreset) -> String {
|
||||
let reasoning_efforts = model
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
let default_suffix = if effort.effort == model.default_reasoning_effort {
|
||||
" (default)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"`{}`{}: {}",
|
||||
effort.effort, default_suffix, effort.description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
|
||||
format!(
|
||||
"- `{}` ({}) — {} Supported reasoning efforts: {}",
|
||||
model.model, model.display_name, model.description, reasoning_efforts
|
||||
)
|
||||
}
|
||||
|
||||
fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -1826,6 +1889,16 @@ pub(crate) fn build_specs(
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
build_specs_with_available_models(config, mcp_tools, app_tools, dynamic_tools, &[])
|
||||
}
|
||||
|
||||
pub(crate) fn build_specs_with_available_models(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
available_models: &[ModelPreset],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ArtifactsHandler;
|
||||
@@ -2046,7 +2119,7 @@ pub(crate) fn build_specs(
|
||||
|
||||
if config.collab_tools {
|
||||
let multi_agent_handler = Arc::new(MultiAgentHandler);
|
||||
builder.push_spec(create_spawn_agent_tool(config));
|
||||
builder.push_spec(create_spawn_agent_tool(config, available_models));
|
||||
builder.push_spec(create_send_input_tool());
|
||||
builder.push_spec(create_resume_agent_tool());
|
||||
builder.push_spec(create_wait_tool());
|
||||
@@ -2111,6 +2184,7 @@ mod tests {
|
||||
use crate::config::test_config;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
use crate::test_support::all_model_presets;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -2505,6 +2579,71 @@ mod tests {
|
||||
assert_lacks_tool_name(&tools, "request_permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_schema_includes_model_overrides() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Collab);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
|
||||
let tool = create_spawn_agent_tool(&tools_config, all_model_presets().as_slice());
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
let JsonSchema::Object { properties, .. } = parameters else {
|
||||
panic!("expected object schema");
|
||||
};
|
||||
|
||||
assert!(properties.contains_key("model"));
|
||||
assert!(properties.contains_key("reasoning_effort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_description_includes_visible_models_only() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Collab);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let all_models = all_model_presets();
|
||||
let visible_model = all_models
|
||||
.iter()
|
||||
.find(|model| model.show_in_picker)
|
||||
.expect("expected a visible model")
|
||||
.clone();
|
||||
let hidden_model = all_models
|
||||
.iter()
|
||||
.find(|model| !model.show_in_picker)
|
||||
.expect("expected a hidden model")
|
||||
.clone();
|
||||
|
||||
let tool = create_spawn_agent_tool(
|
||||
&tools_config,
|
||||
&[visible_model.clone(), hidden_model.clone()],
|
||||
);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
|
||||
assert!(description.contains("### Available models"));
|
||||
assert!(description.contains(&format!("`{}`", visible_model.model)));
|
||||
assert!(description.contains(&visible_model.description));
|
||||
assert!(!description.contains(&format!("`{}`", hidden_model.model)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_memory_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
|
||||
@@ -302,7 +302,13 @@ async fn plugin_mcp_tools_are_listed() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let rmcp_test_server_bin = match stdio_server_bin() {
|
||||
Ok(bin) => bin,
|
||||
Err(err) => {
|
||||
eprintln!("test_stdio_server binary not available, skipping test: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
|
||||
let codex = build_plugin_test_codex(&server, codex_home).await?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user