mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[apps] Add tool_suggest tool. (#14287)
- [x] Add tool_suggest tool. - [x] Move chatgpt/src/connectors.rs and core/src/connectors.rs into a dedicated mod so that we have all the logic and global cache in one place. - [x] Update TUI app link view to support rendering the installation view for mcp elicitation. --------- Co-authored-by: Shaqayeq <shaqayeq@openai.com> Co-authored-by: Eric Traut <etraut@openai.com> Co-authored-by: pakrym-oai <pakrym@openai.com> Co-authored-by: Ahmed Ibrahim <aibrahim@openai.com> Co-authored-by: guinness-oai <guinness@openai.com> Co-authored-by: Eugene Brevdo <ebrevdo@users.noreply.github.com> Co-authored-by: Charlie Guo <cguo@openai.com> Co-authored-by: Fouad Matin <fouad@openai.com> Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Co-authored-by: xl-openai <xl@openai.com> Co-authored-by: alexsong-oai <alexsong@openai.com> Co-authored-by: Owen Lin <owenlin0@gmail.com> Co-authored-by: sdcoffey <stevendcoffey@gmail.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Won Park <won@openai.com> Co-authored-by: Dylan Hurd <dylan.hurd@openai.com> Co-authored-by: celia-oai <celia@openai.com> Co-authored-by: gabec-openai <gabec@openai.com> Co-authored-by: joeytrasatti-openai <joey.trasatti@openai.com> Co-authored-by: Leo Shimonaka <leoshimo@openai.com> Co-authored-by: Rasmus Rygaard <rasmus@openai.com> Co-authored-by: maja-openai <163171781+maja-openai@users.noreply.github.com> Co-authored-by: pash-openai <pash@openai.com> Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
co-authored by
Shaqayeq
Eric Traut
pakrym-oai
Ahmed Ibrahim
guinness-oai
Eugene Brevdo
Charlie Guo
Fouad Matin
Fouad Matin
xl-openai
alexsong-oai
Owen Lin
sdcoffey
Codex
Won Park
Dylan Hurd
celia-oai
gabec-openai
joeytrasatti-openai
Leo Shimonaka
Rasmus Rygaard
maja-openai
pash-openai
Josh McKinney
parent
917c2df201
commit
ba5b94287e
+51
-11
@@ -287,12 +287,14 @@ use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
@@ -6246,7 +6248,7 @@ async fn run_sampling_request(
|
||||
}
|
||||
}
|
||||
|
||||
async fn built_tools(
|
||||
pub(crate) async fn built_tools(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
input: &[ResponseItem],
|
||||
@@ -6269,10 +6271,17 @@ async fn built_tools(
|
||||
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||
|
||||
let connectors = if turn_context.apps_enabled() {
|
||||
let apps_enabled = turn_context.apps_enabled();
|
||||
let accessible_connectors =
|
||||
apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&mcp_tools));
|
||||
let accessible_connectors_with_enabled_state =
|
||||
accessible_connectors.as_ref().map(|connectors| {
|
||||
connectors::with_app_enabled_state(connectors.clone(), &turn_context.config)
|
||||
});
|
||||
let connectors = if apps_enabled {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
accessible_connectors.clone().unwrap_or_default(),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
connectors,
|
||||
@@ -6281,6 +6290,34 @@ async fn built_tools(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let discoverable_tools = if apps_enabled
|
||||
&& turn_context.tools_config.search_tool
|
||||
&& turn_context.tools_config.tool_suggest
|
||||
{
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connectors) if connectors.is_empty() => None,
|
||||
Ok(connectors) => {
|
||||
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Keep the connector-grouped app view around for the router even though
|
||||
// app tools only become prompt-visible after explicit selection/discovery.
|
||||
@@ -6312,14 +6349,17 @@ async fn built_tools(
|
||||
|
||||
Ok(Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
has_mcp_servers.then(|| {
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect()
|
||||
}),
|
||||
app_tools,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: has_mcp_servers.then(|| {
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect()
|
||||
}),
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,12 @@ fn default_image_save_developer_message_text() -> String {
|
||||
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
None,
|
||||
None,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
));
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
ToolCallRuntime::new(router, session, turn_context, tracker)
|
||||
@@ -3954,14 +3957,17 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
let app_tools = Some(tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
Some(
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
let item = ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
|
||||
+331
-10
@@ -9,13 +9,17 @@ use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppBranding;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
pub use codex_app_server_protocol::AppMetadata;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
@@ -24,6 +28,7 @@ use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::default_client::create_client;
|
||||
use crate::default_client::is_first_party_chat_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::features::Feature;
|
||||
@@ -38,8 +43,22 @@ use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
pub use codex_connectors::CONNECTORS_CACHE_TTL;
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
|
||||
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
|
||||
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
|
||||
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
|
||||
"connector_947e0d954944416db111db556030eea6",
|
||||
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
|
||||
"connector_686fad9b54914a35b75be6d06a0f6f31",
|
||||
"connector_76869538009648d5b282a4bb21c3d157",
|
||||
"connector_37316be7febe4224b3d31465bae4dbd7",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AppToolPolicy {
|
||||
@@ -90,6 +109,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
let directory_connectors =
|
||||
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
|
||||
Ok(filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
config: &Config,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
@@ -102,6 +134,21 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cache_key = accessible_connectors_cache_key(config, auth);
|
||||
let accessible_connectors =
|
||||
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools));
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
|
||||
pub async fn list_accessible_connectors_from_mcp_tools_with_options(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
@@ -172,19 +219,33 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
)
|
||||
.await;
|
||||
|
||||
if force_refetch
|
||||
&& let Err(err) = mcp_connection_manager
|
||||
let refreshed_tools = if force_refetch {
|
||||
match mcp_connection_manager
|
||||
.hard_refresh_codex_apps_tools_cache()
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
|
||||
);
|
||||
}
|
||||
{
|
||||
Ok(tools) => Some(tools),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let refreshed_tools_succeeded = refreshed_tools.is_some();
|
||||
|
||||
let mut tools = mcp_connection_manager.list_all_tools().await;
|
||||
let mut tools = if let Some(tools) = refreshed_tools {
|
||||
tools
|
||||
} else {
|
||||
mcp_connection_manager.list_all_tools().await
|
||||
};
|
||||
let mut should_reload_tools = false;
|
||||
let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let codex_apps_ready = if refreshed_tools_succeeded {
|
||||
true
|
||||
} else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let immediate_ready = mcp_connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
|
||||
.await;
|
||||
@@ -281,6 +342,119 @@ fn write_cached_accessible_connectors(
|
||||
});
|
||||
}
|
||||
|
||||
fn filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors: Vec<AppInfo>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> Vec<AppInfo> {
|
||||
let accessible_connector_ids: HashSet<&str> = accessible_connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let mut connectors = filter_disallowed_connectors(directory_connectors)
|
||||
.into_iter()
|
||||
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
|
||||
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
connectors
|
||||
}
|
||||
|
||||
async fn list_directory_connectors_for_tool_suggest_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let token_data = if let Some(auth) = auth {
|
||||
auth.get_token_data().ok()
|
||||
} else {
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.and_then(|auth| auth.get_token_data().ok())
|
||||
};
|
||||
let Some(token_data) = token_data else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let account_id = match token_data.account_id.as_deref() {
|
||||
Some(account_id) if !account_id.is_empty() => account_id,
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
let access_token = token_data.access_token.clone();
|
||||
let account_id = account_id.to_string();
|
||||
let is_workspace_account = token_data.id_token.is_workspace_account();
|
||||
let cache_key = AllConnectorsCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
Some(account_id.clone()),
|
||||
token_data.id_token.chatgpt_user_id.clone(),
|
||||
is_workspace_account,
|
||||
);
|
||||
|
||||
codex_connectors::list_all_connectors_with_options(
|
||||
cache_key,
|
||||
is_workspace_account,
|
||||
false,
|
||||
|path| {
|
||||
let access_token = access_token.clone();
|
||||
let account_id = account_id.clone();
|
||||
async move {
|
||||
chatgpt_get_request_with_token::<DirectoryListResponse>(
|
||||
config,
|
||||
path,
|
||||
access_token.as_str(),
|
||||
account_id.as_str(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn chatgpt_get_request_with_token<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
access_token: &str,
|
||||
account_id: &str,
|
||||
) -> anyhow::Result<T> {
|
||||
let client = create_client();
|
||||
let url = format!("{}{}", config.chatgpt_base_url, path);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("chatgpt-account-id", account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(DIRECTORY_CONNECTORS_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse JSON response")
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("request failed with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
|
||||
AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
@@ -719,10 +893,12 @@ fn format_connector_label(name: &str, _id: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsDefaultConfig;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -730,6 +906,7 @@ mod tests {
|
||||
use rmcp::model::Tool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn annotations(
|
||||
destructive_hint: Option<bool>,
|
||||
@@ -762,6 +939,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn named_app(id: &str, name: &str) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
install_url: Some(connector_install_url(name, id)),
|
||||
..app(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_names(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
@@ -821,6 +1007,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_accessible_connectors_cache_cleared<R>(f: impl FnOnce() -> R) -> R {
|
||||
let previous = {
|
||||
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache_guard.take()
|
||||
};
|
||||
let result = f();
|
||||
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = previous;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
@@ -907,6 +1108,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
let _ = config.features.set_enabled(Feature::Apps, true);
|
||||
let cache_key = accessible_connectors_cache_key(&config, None);
|
||||
let tools = HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar_list_events".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_list_events",
|
||||
"calendar",
|
||||
Some("Google Calendar"),
|
||||
&["calendar-plugin"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__openai_hidden".to_string(),
|
||||
codex_app_tool(
|
||||
"openai_hidden",
|
||||
"connector_openai_hidden",
|
||||
Some("Hidden"),
|
||||
&[],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
let cached = with_accessible_connectors_cache_cleared(|| {
|
||||
refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools);
|
||||
read_cached_accessible_connectors(&cache_key).expect("cache should be populated")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
cached,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url("Google Calendar", "calendar")),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["calendar-plugin"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
|
||||
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
@@ -1344,4 +1601,68 @@ mod tests {
|
||||
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_tools(
|
||||
vec![
|
||||
named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
),
|
||||
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
|
||||
named_app("connector_other", "Other"),
|
||||
],
|
||||
&[AppInfo {
|
||||
is_accessible: true,
|
||||
..named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
)
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![named_app(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_tools(
|
||||
vec![
|
||||
named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
),
|
||||
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
|
||||
],
|
||||
&[
|
||||
AppInfo {
|
||||
is_accessible: true,
|
||||
..named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
)
|
||||
},
|
||||
AppInfo {
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![named_app(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail"
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ pub enum Feature {
|
||||
SpawnCsv,
|
||||
/// Enable apps.
|
||||
Apps,
|
||||
/// Enable discoverable tool suggestions for apps.
|
||||
ToolSuggest,
|
||||
/// Enable plugins.
|
||||
Plugins,
|
||||
/// Allow the model to invoke the built-in image generation tool.
|
||||
@@ -714,6 +716,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ToolSuggest,
|
||||
key: "tool_suggest",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Plugins,
|
||||
key: "plugins",
|
||||
@@ -996,6 +1004,12 @@ mod tests {
|
||||
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_under_development() {
|
||||
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::ToolSuggest.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_is_under_development() {
|
||||
assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment);
|
||||
|
||||
@@ -827,9 +827,10 @@ impl McpConnectionManager {
|
||||
|
||||
/// Force-refresh codex apps tools by bypassing the in-process cache.
|
||||
///
|
||||
/// On success, the refreshed tools replace the cache contents. On failure,
|
||||
/// the existing cache remains unchanged.
|
||||
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<()> {
|
||||
/// On success, the refreshed tools replace the cache contents and the
|
||||
/// latest filtered tool map is returned directly to the caller. On
|
||||
/// failure, the existing cache remains unchanged.
|
||||
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<HashMap<String, ToolInfo>> {
|
||||
let managed_client = self
|
||||
.clients
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
@@ -865,7 +866,10 @@ impl McpConnectionManager {
|
||||
list_start.elapsed(),
|
||||
&[("cache", "miss")],
|
||||
);
|
||||
Ok(())
|
||||
Ok(qualify_tools(filter_tools(
|
||||
tools,
|
||||
&managed_client.tool_filter,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Returns a single map that contains all resources. Each key is the
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text_content_items_with_policy;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
@@ -408,9 +409,12 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
|
||||
ToolRouter::from_config(
|
||||
&nested_tools_config,
|
||||
Some(mcp_tools),
|
||||
None,
|
||||
exec.turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(mcp_tools),
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum DiscoverableToolType {
|
||||
Connector,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl DiscoverableToolType {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Connector => "connector",
|
||||
Self::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum DiscoverableToolAction {
|
||||
Install,
|
||||
Enable,
|
||||
}
|
||||
|
||||
impl DiscoverableToolAction {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Install => "install",
|
||||
Self::Enable => "enable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum DiscoverableTool {
|
||||
Connector(Box<AppInfo>),
|
||||
Plugin(Box<DiscoverablePluginInfo>),
|
||||
}
|
||||
|
||||
impl DiscoverableTool {
|
||||
pub(crate) fn tool_type(&self) -> DiscoverableToolType {
|
||||
match self {
|
||||
Self::Connector(_) => DiscoverableToolType::Connector,
|
||||
Self::Plugin(_) => DiscoverableToolType::Plugin,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.id.as_str(),
|
||||
Self::Plugin(plugin) => plugin.id.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.name.as_str(),
|
||||
Self::Plugin(plugin) => plugin.name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn description(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.description.as_deref(),
|
||||
Self::Plugin(plugin) => plugin.description.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppInfo> for DiscoverableTool {
|
||||
fn from(value: AppInfo) -> Self {
|
||||
Self::Connector(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DiscoverablePluginInfo> for DiscoverableTool {
|
||||
fn from(value: DiscoverablePluginInfo) -> Self {
|
||||
Self::Plugin(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DiscoverablePluginInfo {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) has_skills: bool,
|
||||
pub(crate) mcp_server_names: Vec<String>,
|
||||
pub(crate) app_connector_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
|
||||
fn from(value: PluginCapabilitySummary) -> Self {
|
||||
Self {
|
||||
id: value.config_name,
|
||||
name: value.display_name,
|
||||
description: value.description,
|
||||
has_skills: value.has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
app_connector_ids: value
|
||||
.app_connector_ids
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod request_user_input;
|
||||
mod shell;
|
||||
mod test_sync;
|
||||
mod tool_search;
|
||||
mod tool_suggest;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
@@ -56,6 +57,8 @@ pub use test_sync::TestSyncHandler;
|
||||
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
|
||||
pub use tool_search::ToolSearchHandler;
|
||||
pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME;
|
||||
pub use tool_suggest::ToolSuggestHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct ToolSuggestHandler;
|
||||
|
||||
pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
|
||||
const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolSuggestArgs {
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: String,
|
||||
suggest_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
struct ToolSuggestResult {
|
||||
completed: bool,
|
||||
user_confirmed: bool,
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
suggest_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
struct ToolSuggestMeta<'a> {
|
||||
codex_approval_kind: &'static str,
|
||||
tool_type: DiscoverableToolType,
|
||||
suggest_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ToolSuggestHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::Fatal(format!(
|
||||
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
|
||||
let suggest_reason = args.suggest_reason.trim();
|
||||
if suggest_reason.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"suggest_reason must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if args.tool_type == DiscoverableToolType::Plugin {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"plugin tool suggestions are not currently available".to_string(),
|
||||
));
|
||||
}
|
||||
if args.action_type != DiscoverableToolAction::Install {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"connector tool suggestions currently support only action_type=\"install\""
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
drop(manager);
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
.map(|connectors| {
|
||||
connectors
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool suggestions are unavailable right now: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let connector = discoverable_tools
|
||||
.into_iter()
|
||||
.find_map(|tool| match tool {
|
||||
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
|
||||
Some(*connector)
|
||||
}
|
||||
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
|
||||
let params = build_tool_suggestion_elicitation_request(
|
||||
session.conversation_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
&args,
|
||||
suggest_reason,
|
||||
&connector,
|
||||
);
|
||||
let response = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
.await;
|
||||
let user_confirmed = response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let completed = if user_confirmed {
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&mcp_tools,
|
||||
);
|
||||
verified_connector_suggestion_completed(
|
||||
args.action_type,
|
||||
connector.id.as_str(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
|
||||
connector.id
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if completed {
|
||||
session
|
||||
.merge_connector_selection(HashSet::from([connector.id.clone()]))
|
||||
.await;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&ToolSuggestResult {
|
||||
completed,
|
||||
user_confirmed,
|
||||
tool_type: args.tool_type,
|
||||
action_type: args.action_type,
|
||||
tool_id: connector.id,
|
||||
tool_name: connector.name,
|
||||
suggest_reason: suggest_reason.to_string(),
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_suggestion_elicitation_request(
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
args: &ToolSuggestArgs,
|
||||
suggest_reason: &str,
|
||||
connector: &AppInfo,
|
||||
) -> McpServerElicitationRequestParams {
|
||||
let tool_name = connector.name.clone();
|
||||
let install_url = connector
|
||||
.install_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
|
||||
|
||||
let message = format!(
|
||||
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
|
||||
args.action_type.as_str()
|
||||
);
|
||||
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id,
|
||||
turn_id: Some(turn_id),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(build_tool_suggestion_meta(
|
||||
args.tool_type,
|
||||
args.action_type,
|
||||
suggest_reason,
|
||||
connector.id.as_str(),
|
||||
tool_name.as_str(),
|
||||
install_url.as_str(),
|
||||
))),
|
||||
message,
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_suggestion_meta<'a>(
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
) -> ToolSuggestMeta<'a> {
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type,
|
||||
suggest_type: action_type,
|
||||
suggest_reason,
|
||||
tool_id,
|
||||
tool_name,
|
||||
install_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn verified_connector_suggestion_completed(
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: &str,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> bool {
|
||||
accessible_connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == tool_id)
|
||||
.is_some_and(|connector| match action_type {
|
||||
DiscoverableToolAction::Install => connector.is_accessible,
|
||||
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
let args = ToolSuggestArgs {
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
action_type: DiscoverableToolAction::Install,
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
suggest_reason: "Plan and reference events from your calendar".to_string(),
|
||||
};
|
||||
let connector = AppInfo {
|
||||
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(
|
||||
"https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
|
||||
.to_string(),
|
||||
),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
};
|
||||
|
||||
let request = build_tool_suggestion_elicitation_request(
|
||||
"thread-1".to_string(),
|
||||
"turn-1".to_string(),
|
||||
&args,
|
||||
"Plan and reference events from your calendar",
|
||||
&connector,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar",
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44",
|
||||
tool_name: "Google Calendar",
|
||||
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
|
||||
})),
|
||||
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_meta_uses_expected_shape() {
|
||||
let meta = build_tool_suggestion_meta(
|
||||
DiscoverableToolType::Connector,
|
||||
DiscoverableToolAction::Install,
|
||||
"Find and reference emails from your inbox",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
meta,
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Find and reference emails from your inbox",
|
||||
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
|
||||
tool_name: "Gmail",
|
||||
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_connector_suggestion_completed_requires_installed_connector() {
|
||||
let accessible_connectors = vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert!(verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Install,
|
||||
"calendar",
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(!verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Install,
|
||||
"gmail",
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
|
||||
let accessible_connectors = vec![
|
||||
AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "gmail".to_string(),
|
||||
name: "Gmail".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(!verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Enable,
|
||||
"calendar",
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Enable,
|
||||
"gmail",
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1360,14 +1360,17 @@ impl JsReplManager {
|
||||
|
||||
let router = ToolRouter::from_config(
|
||||
&exec.turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
None,
|
||||
exec.turn.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let payload = if let Some((server, tool)) = exec
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod code_mode;
|
||||
pub(crate) mod code_mode_description;
|
||||
pub mod context;
|
||||
pub(crate) mod discoverable;
|
||||
pub mod events;
|
||||
pub(crate) mod handlers;
|
||||
pub mod js_repl;
|
||||
|
||||
@@ -9,11 +9,12 @@ use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
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_discoverable_tools;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
@@ -40,14 +41,28 @@ pub struct ToolRouter {
|
||||
specs: Vec<ConfiguredToolSpec>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolRouterParams<'a> {
|
||||
pub(crate) mcp_tools: Option<HashMap<String, Tool>>,
|
||||
pub(crate) app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
|
||||
}
|
||||
|
||||
impl ToolRouter {
|
||||
pub fn from_config(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> Self {
|
||||
let builder = build_specs(config, mcp_tools, app_tools, dynamic_tools);
|
||||
pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self {
|
||||
let ToolRouterParams {
|
||||
mcp_tools,
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
} = params;
|
||||
let builder = build_specs_with_discoverable_tools(
|
||||
config,
|
||||
mcp_tools,
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
);
|
||||
let (specs, registry) = builder.build();
|
||||
|
||||
Self { registry, specs }
|
||||
@@ -287,6 +302,7 @@ mod tests {
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
use super::ToolRouter;
|
||||
use super::ToolRouterParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
@@ -305,14 +321,17 @@ mod tests {
|
||||
let app_tools = Some(mcp_tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
@@ -359,14 +378,17 @@ mod tests {
|
||||
let app_tools = Some(mcp_tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
|
||||
@@ -10,9 +10,14 @@ use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::handlers::PLAN_TOOL;
|
||||
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
|
||||
use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME;
|
||||
use crate::tools::handlers::agent_jobs::BatchJobHandler;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
@@ -44,6 +49,8 @@ use std::collections::HashMap;
|
||||
|
||||
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_suggest_description.md");
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
|
||||
fn unified_exec_output_schema() -> JsonValue {
|
||||
@@ -105,6 +112,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub image_gen_tool: bool,
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub tool_suggest: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
pub request_permissions_tool_enabled: bool,
|
||||
pub code_mode_enabled: bool,
|
||||
@@ -148,6 +156,7 @@ impl ToolsConfig {
|
||||
let include_default_mode_request_user_input =
|
||||
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest);
|
||||
let include_original_image_detail = can_request_original_image_detail(features, model_info);
|
||||
let include_artifact_tools =
|
||||
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
|
||||
@@ -215,6 +224,7 @@ impl ToolsConfig {
|
||||
image_gen_tool: include_image_gen_tool,
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
tool_suggest: include_tool_suggest,
|
||||
request_permission_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
code_mode_enabled: include_code_mode,
|
||||
@@ -1451,6 +1461,133 @@ fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec {
|
||||
let discoverable_tool_ids = discoverable_tools
|
||||
.iter()
|
||||
.map(DiscoverableTool::id)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"tool_type".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"action_type".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Suggested action for the tool. Use \"install\" or \"enable\".".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tool_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(format!(
|
||||
"Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}."
|
||||
)),
|
||||
},
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Concise one-line user-facing reason why this tool can help with the current request."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace(
|
||||
"{{discoverable_tools}}",
|
||||
format_discoverable_tools(discoverable_tools).as_str(),
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String {
|
||||
let mut discoverable_tools = discoverable_tools.to_vec();
|
||||
discoverable_tools.sort_by(|left, right| {
|
||||
left.name()
|
||||
.cmp(right.name())
|
||||
.then_with(|| left.id().cmp(right.id()))
|
||||
});
|
||||
|
||||
discoverable_tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let description = tool
|
||||
.description()
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| match &tool {
|
||||
DiscoverableTool::Connector(_) => "No description provided.".to_string(),
|
||||
DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()),
|
||||
});
|
||||
let default_action = match tool.tool_type() {
|
||||
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
|
||||
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
|
||||
};
|
||||
format!(
|
||||
"- {} (id: `{}`, type: {}, action: {}): {}",
|
||||
tool.name(),
|
||||
tool.id(),
|
||||
tool.tool_type().as_str(),
|
||||
default_action.as_str(),
|
||||
description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String {
|
||||
let mut details = Vec::new();
|
||||
if plugin.has_skills {
|
||||
details.push("skills".to_string());
|
||||
}
|
||||
if !plugin.mcp_server_names.is_empty() {
|
||||
details.push(format!(
|
||||
"MCP servers: {}",
|
||||
plugin.mcp_server_names.join(", ")
|
||||
));
|
||||
}
|
||||
if !plugin.app_connector_ids.is_empty() {
|
||||
details.push(format!(
|
||||
"app connectors: {}",
|
||||
plugin.app_connector_ids.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
"No description provided.".to_string()
|
||||
} else {
|
||||
details.join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
fn create_read_file_tool() -> ToolSpec {
|
||||
let indentation_properties = BTreeMap::from([
|
||||
(
|
||||
@@ -2083,11 +2220,22 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
}
|
||||
|
||||
/// Builds the tool registry builder while collecting tool specs for later serialization.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_specs(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
build_specs_with_discoverable_tools(config, mcp_tools, app_tools, None, dynamic_tools)
|
||||
}
|
||||
|
||||
pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ArtifactsHandler;
|
||||
@@ -2108,6 +2256,7 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandler;
|
||||
use crate::tools::handlers::ToolSuggestHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
use std::sync::Arc;
|
||||
@@ -2127,6 +2276,7 @@ pub(crate) fn build_specs(
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler {
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
|
||||
let code_mode_handler = Arc::new(CodeModeHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
@@ -2135,10 +2285,11 @@ pub(crate) fn build_specs(
|
||||
|
||||
if config.code_mode_enabled {
|
||||
let nested_config = config.for_code_mode_nested_tools();
|
||||
let (nested_specs, _) = build_specs(
|
||||
let (nested_specs, _) = build_specs_with_discoverable_tools(
|
||||
&nested_config,
|
||||
mcp_tools.clone(),
|
||||
app_tools.clone(),
|
||||
None,
|
||||
dynamic_tools,
|
||||
)
|
||||
.build();
|
||||
@@ -2304,6 +2455,15 @@ pub(crate) fn build_specs(
|
||||
}
|
||||
}
|
||||
|
||||
if config.tool_suggest
|
||||
&& let Some(discoverable_tools) = discoverable_tools
|
||||
.as_ref()
|
||||
.filter(|tools| !tools.is_empty())
|
||||
{
|
||||
builder.push_spec_with_parallel_support(create_tool_suggest_tool(discoverable_tools), true);
|
||||
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
|
||||
}
|
||||
|
||||
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
|
||||
match apply_patch_tool_type {
|
||||
ApplyPatchToolType::Freeform => {
|
||||
@@ -2565,6 +2725,7 @@ mod tests {
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -2590,6 +2751,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
||||
let slug = name.replace(' ', "-").to_lowercase();
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_openai_tool_inserts_empty_properties() {
|
||||
let mut schema = rmcp::model::JsonObject::new();
|
||||
@@ -4147,7 +4327,6 @@ mod tests {
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
let available_models = Vec::new();
|
||||
@@ -4162,6 +4341,41 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
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::Apps);
|
||||
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::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
None,
|
||||
None,
|
||||
Some(vec![discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
)]),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
assert!(
|
||||
!tools
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_description_handles_no_enabled_apps() {
|
||||
let config = test_config();
|
||||
@@ -4253,6 +4467,89 @@ mod tests {
|
||||
assert!(registry.has_handler(alias.as_str(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_description_lists_discoverable_tools() {
|
||||
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::Apps);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
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::Cli,
|
||||
});
|
||||
|
||||
let discoverable_tools = vec![
|
||||
discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
),
|
||||
discoverable_connector(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
"Find and summarize email threads.",
|
||||
),
|
||||
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "sample@test".to_string(),
|
||||
name: "Sample Plugin".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec!["connector_sample".to_string()],
|
||||
})),
|
||||
];
|
||||
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
None,
|
||||
None,
|
||||
Some(discoverable_tools),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
parameters,
|
||||
..
|
||||
}) = &tool_suggest.spec
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert!(description.contains("Google Calendar"));
|
||||
assert!(description.contains("Gmail"));
|
||||
assert!(description.contains("Sample Plugin"));
|
||||
assert!(description.contains("Plan events and schedules."));
|
||||
assert!(description.contains("Find and summarize email threads."));
|
||||
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
|
||||
assert!(
|
||||
description
|
||||
.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
|
||||
);
|
||||
assert!(
|
||||
description.contains("DO NOT explore or recommend tools that are not on this list.")
|
||||
);
|
||||
let JsonSchema::Object { required, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
assert_eq!(
|
||||
required.as_ref(),
|
||||
Some(&vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
let config = test_config();
|
||||
|
||||
Reference in New Issue
Block a user