[tool_suggest] More prompt polishes. (#20566)

Tool suggest still misfires when model needs tool_search, updating the
prompts to further disambiguate it:

- [x] rename it from `tool_suggest` to `request_plugin_install`
- [x] rephrase "suggestion" to "install" in the tool descriptions.
- [x] disambiguate "the tool" vs "the plugin/connector". 

Tested with the Codex App and verified it still works.
This commit is contained in:
Matthew Zeng
2026-05-01 21:22:12 -07:00
committed by GitHub
parent 127434cd8b
commit f88701f5c8
19 changed files with 253 additions and 232 deletions

View File

@@ -97,7 +97,7 @@ use codex_protocol::protocol::TurnDiffEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -1170,7 +1170,7 @@ pub(crate) async fn built_tools(
)
.await
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)

View File

@@ -10,11 +10,11 @@ pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_v2;
mod plan;
mod request_permissions;
mod request_plugin_install;
mod request_user_input;
mod shell;
mod test_sync;
mod tool_search;
mod tool_suggest;
mod unavailable_tool;
pub(crate) mod unified_exec;
mod view_image;
@@ -43,12 +43,12 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use tool_search::ToolSearchHandler;
pub use tool_suggest::ToolSuggestHandler;
pub use unavailable_tool::UnavailableToolHandler;
pub(crate) use unavailable_tool::unavailable_tool_message;
pub use unified_exec::UnifiedExecHandler;

View File

@@ -8,15 +8,15 @@ use codex_rmcp_client::ElicitationResponse;
use codex_tools::DiscoverableTool;
use codex_tools::DiscoverableToolAction;
use codex_tools::DiscoverableToolType;
use codex_tools::TOOL_SUGGEST_PERSIST_ALWAYS_VALUE;
use codex_tools::TOOL_SUGGEST_PERSIST_KEY;
use codex_tools::TOOL_SUGGEST_TOOL_NAME;
use codex_tools::ToolSuggestArgs;
use codex_tools::ToolSuggestResult;
use codex_tools::all_suggested_connectors_picked_up;
use codex_tools::build_tool_suggestion_elicitation_request;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_tools::verified_connector_suggestion_completed;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallArgs;
use codex_tools::RequestPluginInstallResult;
use codex_tools::all_requested_connectors_picked_up;
use codex_tools::build_request_plugin_install_elicitation_request;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_tools::verified_connector_install_completed;
use rmcp::model::RequestId;
use serde_json::Value;
use tracing::warn;
@@ -32,9 +32,9 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct ToolSuggestHandler;
pub struct RequestPluginInstallHandler;
impl ToolHandler for ToolSuggestHandler {
impl ToolHandler for RequestPluginInstallHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
@@ -43,7 +43,7 @@ impl ToolHandler for ToolSuggestHandler {
#[expect(
clippy::await_holding_invalid_type,
reason = "tool suggestion discovery reads through the session-owned manager guard"
reason = "plugin install discovery reads through the session-owned manager guard"
)]
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
@@ -58,12 +58,12 @@ impl ToolHandler for ToolSuggestHandler {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
"{REQUEST_PLUGIN_INSTALL_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
let args: RequestPluginInstallArgs = parse_arguments(&arguments)?;
let suggest_reason = args.suggest_reason.trim();
if suggest_reason.is_empty() {
return Err(FunctionCallError::RespondToModel(
@@ -72,14 +72,15 @@ impl ToolHandler for ToolSuggestHandler {
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"tool suggestions currently support only action_type=\"install\"".to_string(),
"plugin install requests currently support only action_type=\"install\""
.to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
{
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not available in codex-tui yet".to_string(),
"plugin install requests are not available in codex-tui yet".to_string(),
));
}
@@ -98,14 +99,14 @@ impl ToolHandler for ToolSuggestHandler {
)
.await
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"tool suggestions are unavailable right now: {err}"
"plugin install requests are unavailable right now: {err}"
))
})?;
@@ -114,12 +115,12 @@ impl ToolHandler for ToolSuggestHandler {
.find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
"tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}"
))
})?;
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
let params = build_tool_suggestion_elicitation_request(
let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into());
let params = build_request_plugin_install_elicitation_request(
CODEX_APPS_MCP_SERVER_NAME,
session.conversation_id.to_string(),
turn.sub_id.clone(),
@@ -131,14 +132,14 @@ impl ToolHandler for ToolSuggestHandler {
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
.await;
if let Some(response) = response.as_ref() {
maybe_persist_tool_suggest_disable(&session, &turn, &tool, response).await;
maybe_persist_disabled_install_request(&session, &turn, &tool, response).await;
}
let user_confirmed = response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
false
};
@@ -149,7 +150,7 @@ impl ToolHandler for ToolSuggestHandler {
.await;
}
let content = serde_json::to_string(&ToolSuggestResult {
let content = serde_json::to_string(&RequestPluginInstallResult {
completed,
user_confirmed,
tool_type: args.tool_type,
@@ -160,7 +161,7 @@ impl ToolHandler for ToolSuggestHandler {
})
.map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}"
"failed to serialize {REQUEST_PLUGIN_INSTALL_TOOL_NAME} response: {err}"
))
})?;
@@ -168,17 +169,17 @@ impl ToolHandler for ToolSuggestHandler {
}
}
async fn maybe_persist_tool_suggest_disable(
async fn maybe_persist_disabled_install_request(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
tool: &DiscoverableTool,
response: &ElicitationResponse,
) {
if !tool_suggest_response_requests_persistent_disable(response) {
if !request_plugin_install_response_requests_persistent_disable(response) {
return;
}
if let Err(err) = persist_tool_suggest_disable(&turn.config.codex_home, tool).await {
if let Err(err) = persist_disabled_install_request(&turn.config.codex_home, tool).await {
warn!(
error = %err,
tool_id = tool.id(),
@@ -190,7 +191,9 @@ async fn maybe_persist_tool_suggest_disable(
session.reload_user_config_layer().await;
}
fn tool_suggest_response_requests_persistent_disable(response: &ElicitationResponse) -> bool {
fn request_plugin_install_response_requests_persistent_disable(
response: &ElicitationResponse,
) -> bool {
if response.action != ElicitationAction::Decline {
return false;
}
@@ -199,24 +202,24 @@ fn tool_suggest_response_requests_persistent_disable(response: &ElicitationRespo
.meta
.as_ref()
.and_then(Value::as_object)
.and_then(|meta| meta.get(TOOL_SUGGEST_PERSIST_KEY))
.and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
.and_then(Value::as_str)
== Some(TOOL_SUGGEST_PERSIST_ALWAYS_VALUE)
== Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
}
async fn persist_tool_suggest_disable(
async fn persist_disabled_install_request(
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
tool: &DiscoverableTool,
) -> anyhow::Result<()> {
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::AddToolSuggestDisabledTool(
disabled_tool_suggestion(tool),
disabled_install_request(tool),
)])
.apply()
.await
}
fn disabled_tool_suggestion(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
match tool {
DiscoverableTool::Connector(connector) => {
ToolSuggestDisabledTool::connector(connector.id.as_str())
@@ -225,14 +228,14 @@ fn disabled_tool_suggestion(tool: &DiscoverableTool) -> ToolSuggestDisabledTool
}
}
async fn verify_tool_suggestion_completed(
async fn verify_request_plugin_install_completed(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
tool: &DiscoverableTool,
auth: Option<&codex_login::CodexAuth>,
) -> bool {
match tool {
DiscoverableTool::Connector(connector) => refresh_missing_suggested_connectors(
DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
session,
turn,
auth,
@@ -241,17 +244,17 @@ async fn verify_tool_suggestion_completed(
)
.await
.is_some_and(|accessible_connectors| {
verified_connector_suggestion_completed(connector.id.as_str(), &accessible_connectors)
verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
}),
DiscoverableTool::Plugin(plugin) => {
session.reload_user_config_layer().await;
let config = session.get_config().await;
let completed = verified_plugin_suggestion_completed(
let completed = verified_plugin_install_completed(
plugin.id.as_str(),
config.as_ref(),
session.services.plugins_manager.as_ref(),
);
let _ = refresh_missing_suggested_connectors(
let _ = refresh_missing_requested_connectors(
session,
turn,
auth,
@@ -268,7 +271,7 @@ async fn verify_tool_suggestion_completed(
clippy::await_holding_invalid_type,
reason = "connector cache refresh reads through the session-owned manager guard"
)]
async fn refresh_missing_suggested_connectors(
async fn refresh_missing_requested_connectors(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
auth: Option<&codex_login::CodexAuth>,
@@ -285,7 +288,7 @@ async fn refresh_missing_suggested_connectors(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
if all_suggested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
return Some(accessible_connectors);
}
@@ -304,14 +307,14 @@ async fn refresh_missing_suggested_connectors(
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}"
"failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}"
);
None
}
}
}
fn verified_plugin_suggestion_completed(
fn verified_plugin_install_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &codex_core_plugins::PluginsManager,
@@ -327,5 +330,5 @@ fn verified_plugin_suggestion_completed(
}
#[cfg(test)]
#[path = "tool_suggest_tests.rs"]
#[path = "request_plugin_install_tests.rs"]
mod tests;

View File

@@ -22,7 +22,7 @@ use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
async fn verified_plugin_install_completed_requires_installed_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample"]);
@@ -32,7 +32,7 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
let config = load_plugins_config(codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
assert!(!verified_plugin_suggestion_completed(
assert!(!verified_plugin_install_completed(
"sample@openai-curated",
&config,
&plugins_manager,
@@ -50,7 +50,7 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(
assert!(verified_plugin_install_completed(
"sample@openai-curated",
&refreshed_config,
&plugins_manager,
@@ -58,43 +58,47 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
}
#[test]
fn tool_suggest_response_persists_only_decline_always_mode() {
assert!(tool_suggest_response_requests_persistent_disable(
fn request_plugin_install_response_persists_only_decline_always_mode() {
assert!(request_plugin_install_response_requests_persistent_disable(
&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
meta: Some(json!({
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
})),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
meta: Some(json!({
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
})),
})
);
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: "session" })),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
meta: Some(json!({ REQUEST_PLUGIN_INSTALL_PERSIST_KEY: "session" })),
})
);
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
}
));
})
);
}
#[tokio::test]
async fn persist_tool_suggest_disable_writes_connector_config() {
async fn persist_disabled_install_request_writes_connector_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = connector_tool("connector_calendar", "Google Calendar");
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist connector disable");
@@ -111,7 +115,7 @@ async fn persist_tool_suggest_disable_writes_connector_config() {
}
#[tokio::test]
async fn persist_tool_suggest_disable_writes_plugin_config() {
async fn persist_disabled_install_request_writes_plugin_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
@@ -122,7 +126,7 @@ async fn persist_tool_suggest_disable_writes_plugin_config() {
app_connector_ids: Vec::new(),
}));
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist plugin disable");
@@ -139,7 +143,7 @@ async fn persist_tool_suggest_disable_writes_plugin_config() {
}
#[tokio::test]
async fn persist_tool_suggest_disable_dedupes_existing_disabled_tools() {
async fn persist_disabled_install_request_dedupes_existing_disabled_tools() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = connector_tool("connector_calendar", "Google Calendar");
std::fs::write(
@@ -169,7 +173,7 @@ id = "slack@openai-curated"
)
.expect("write config");
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist connector disable");

View File

@@ -86,12 +86,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnavailableToolHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
@@ -174,7 +174,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
.cloned()
.collect::<Vec<_>>();
let mut tool_search_handler = None;
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
let request_plugin_install_handler = Arc::new(RequestPluginInstallHandler);
let code_mode_handler = Arc::new(CodeModeExecuteHandler);
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
@@ -281,8 +281,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler(handler.name, tool_search_handler.clone());
}
}
ToolHandlerKind::ToolSuggest => {
builder.register_handler(handler.name, tool_suggest_handler.clone());
ToolHandlerKind::RequestPluginInstall => {
builder.register_handler(handler.name, request_plugin_install_handler.clone());
}
ToolHandlerKind::UnifiedExec => {
builder.register_handler(handler.name, unified_exec_handler.clone());

View File

@@ -21,11 +21,11 @@ use codex_tools::ConfiguredToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
use codex_tools::LoadableToolSpec;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::TOOL_SUGGEST_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::ToolsConfig;
@@ -791,7 +791,7 @@ async fn multi_agent_v2_wait_agent_schema_uses_configured_min_timeout() {
}
#[tokio::test]
async fn tool_suggest_requires_apps_and_plugins_features() {
async fn request_plugin_install_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info().await;
let discoverable_tools = Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
@@ -831,7 +831,7 @@ async fn tool_suggest_requires_apps_and_plugins_features() {
assert!(
!tools
.iter()
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME),
.any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}

View File

@@ -0,0 +1,29 @@
# Request plugin/connector install
Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.
Use this ONLY when all of the following are true:
- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.
- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.
- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.
Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.
Known plugins/connectors available to install:
{{discoverable_tools}}
Workflow:
1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.
2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.
3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.
4. If one plugin or connector clearly fits, call `request_plugin_install` with:
- `tool_type`: `connector` or `plugin`
- `action_type`: `install`
- `tool_id`: exact id from the known plugin/connector list above
- `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request
5. After the request flow completes:
- if the user finished the install flow, continue by searching again or using the newly available plugin or connector
- if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.
IMPORTANT: DO NOT call this tool in parallel with other tools.

View File

@@ -1,29 +0,0 @@
# Tool suggestion discovery
Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.
Use this ONLY when all of the following are true:
- The user explicitly wants a specific plugin or connector that is not already available in the current context or active `tools` list.
- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.
- The tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list.
Do not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful. The user's intent must clearly match one listed tool.
Known plugins/connectors available to install:
{{discoverable_tools}}
Workflow:
1. Check the current context and active `tools` list first. If `tool_search` is available, call `tool_search` before calling `tool_suggest`. Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery.
2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.
3. If we found both connectors and plugins to suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.
4. If one tool clearly fits, call `tool_suggest` with:
- `tool_type`: `connector` or `plugin`
- `action_type`: `install`
- `tool_id`: exact id from the known plugin/connector list above
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
5. After the suggestion flow completes:
- if the user finished the install flow, continue by searching again or using the newly available tool
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.
IMPORTANT: DO NOT call this tool in parallel with other tools.

View File

@@ -77,6 +77,7 @@ mod request_compression;
mod request_permissions;
#[cfg(not(target_os = "windows"))]
mod request_permissions_tool;
mod request_plugin_install;
mod request_user_input;
mod responses_api_proxy_headers;
mod resume;
@@ -98,7 +99,6 @@ mod stream_no_completed;
mod subagent_notifications;
mod tool_harness;
mod tool_parallelism;
mod tool_suggest;
mod tools;
mod truncation;
mod turn_state;

View File

@@ -22,7 +22,7 @@ use core_test_support::test_codex::test_codex;
use serde_json::Value;
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
fn tool_names(body: &Value) -> Vec<String> {
@@ -89,7 +89,8 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts() -> Result<()> {
async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts()
-> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -125,18 +126,23 @@ async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts(
"tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}"
);
assert!(
tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME),
"tools list should include {TOOL_SUGGEST_TOOL_NAME}: {tools:?}"
tools
.iter()
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}"
);
let description =
function_tool_description(&body, TOOL_SUGGEST_TOOL_NAME).expect("description");
function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description");
assert!(description.contains(
"Use this tool only to ask the user to install one known plugin or connector from the list below"
));
assert!(description.contains(
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
));
assert!(description.contains(
"Only use when the user explicitly asks to use that exact listed plugin or connector."
));
assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools."));
assert!(!description.contains("tool_search fails to find a good match"));

View File

@@ -27,7 +27,7 @@ schema and Responses API tool primitives that no longer need to live in
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
`request_user_input`, and CSV fanout/reporting
- discoverable-tool models, client filtering, and `ToolSpec` builders for
`tool_search` and `tool_suggest`
`tool_search` and `request_plugin_install`
- `parse_tool_input_schema()`
- `parse_dynamic_tool()`
- `parse_mcp_tool()`

View File

@@ -13,6 +13,7 @@ mod local_tool;
mod mcp_resource_tool;
mod mcp_tool;
mod plan_tool;
mod request_plugin_install;
mod request_user_input_tool;
mod responses_api;
mod tool_config;
@@ -21,7 +22,6 @@ mod tool_discovery;
mod tool_registry_plan;
mod tool_registry_plan_types;
mod tool_spec;
mod tool_suggest;
mod utility_tool;
mod view_image;
@@ -80,6 +80,15 @@ pub use mcp_resource_tool::create_read_mcp_resource_tool;
pub use mcp_tool::mcp_call_tool_result_output_schema;
pub use mcp_tool::parse_mcp_tool;
pub use plan_tool::create_update_plan_tool;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
pub use request_plugin_install::RequestPluginInstallArgs;
pub use request_plugin_install::RequestPluginInstallMeta;
pub use request_plugin_install::RequestPluginInstallResult;
pub use request_plugin_install::all_requested_connectors_picked_up;
pub use request_plugin_install::build_request_plugin_install_elicitation_request;
pub use request_plugin_install::verified_connector_install_completed;
pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME;
pub use request_user_input_tool::create_request_user_input_tool;
pub use request_user_input_tool::normalize_request_user_input_args;
@@ -110,18 +119,18 @@ pub use tool_discovery::DiscoverablePluginInfo;
pub use tool_discovery::DiscoverableTool;
pub use tool_discovery::DiscoverableToolAction;
pub use tool_discovery::DiscoverableToolType;
pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
pub use tool_discovery::RequestPluginInstallEntry;
pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT;
pub use tool_discovery::TOOL_SEARCH_TOOL_NAME;
pub use tool_discovery::TOOL_SUGGEST_TOOL_NAME;
pub use tool_discovery::ToolSearchResultSource;
pub use tool_discovery::ToolSearchSource;
pub use tool_discovery::ToolSearchSourceInfo;
pub use tool_discovery::ToolSuggestEntry;
pub use tool_discovery::collect_request_plugin_install_entries;
pub use tool_discovery::collect_tool_search_source_infos;
pub use tool_discovery::collect_tool_suggest_entries;
pub use tool_discovery::create_request_plugin_install_tool;
pub use tool_discovery::create_tool_search_tool;
pub use tool_discovery::create_tool_suggest_tool;
pub use tool_discovery::filter_tool_suggest_discoverable_tools_for_client;
pub use tool_discovery::filter_request_plugin_install_discoverable_tools_for_client;
pub use tool_discovery::tool_search_result_source_to_loadable_tool_spec;
pub use tool_registry_plan::build_tool_registry_plan;
pub use tool_registry_plan_types::ToolHandlerKind;
@@ -140,15 +149,6 @@ pub use tool_spec::create_image_generation_tool;
pub use tool_spec::create_local_shell_tool;
pub use tool_spec::create_tools_json_for_responses_api;
pub use tool_spec::create_web_search_tool;
pub use tool_suggest::TOOL_SUGGEST_APPROVAL_KIND_VALUE;
pub use tool_suggest::TOOL_SUGGEST_PERSIST_ALWAYS_VALUE;
pub use tool_suggest::TOOL_SUGGEST_PERSIST_KEY;
pub use tool_suggest::ToolSuggestArgs;
pub use tool_suggest::ToolSuggestMeta;
pub use tool_suggest::ToolSuggestResult;
pub use tool_suggest::all_suggested_connectors_picked_up;
pub use tool_suggest::build_tool_suggestion_elicitation_request;
pub use tool_suggest::verified_connector_suggestion_completed;
pub use utility_tool::create_list_dir_tool;
pub use utility_tool::create_test_sync_tool;
pub use view_image::ViewImageToolOptions;

View File

@@ -13,12 +13,12 @@ use crate::DiscoverableTool;
use crate::DiscoverableToolAction;
use crate::DiscoverableToolType;
pub const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
pub const TOOL_SUGGEST_PERSIST_KEY: &str = "persist";
pub const TOOL_SUGGEST_PERSIST_ALWAYS_VALUE: &str = "always";
pub const REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
pub const REQUEST_PLUGIN_INSTALL_PERSIST_KEY: &str = "persist";
pub const REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE: &str = "always";
#[derive(Debug, Deserialize)]
pub struct ToolSuggestArgs {
pub struct RequestPluginInstallArgs {
pub tool_type: DiscoverableToolType,
pub action_type: DiscoverableToolAction,
pub tool_id: String,
@@ -26,7 +26,7 @@ pub struct ToolSuggestArgs {
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ToolSuggestResult {
pub struct RequestPluginInstallResult {
pub completed: bool,
pub user_confirmed: bool,
pub tool_type: DiscoverableToolType,
@@ -37,7 +37,7 @@ pub struct ToolSuggestResult {
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ToolSuggestMeta<'a> {
pub struct RequestPluginInstallMeta<'a> {
pub codex_approval_kind: &'static str,
pub persist: &'static str,
pub tool_type: DiscoverableToolType,
@@ -49,11 +49,11 @@ pub struct ToolSuggestMeta<'a> {
pub install_url: Option<&'a str>,
}
pub fn build_tool_suggestion_elicitation_request(
pub fn build_request_plugin_install_elicitation_request(
server_name: &str,
thread_id: String,
turn_id: String,
args: &ToolSuggestArgs,
args: &RequestPluginInstallArgs,
suggest_reason: &str,
tool: &DiscoverableTool,
) -> McpServerElicitationRequestParams {
@@ -66,7 +66,7 @@ pub fn build_tool_suggestion_elicitation_request(
turn_id: Some(turn_id),
server_name: server_name.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(build_tool_suggestion_meta(
meta: Some(json!(build_request_plugin_install_meta(
args.tool_type,
args.action_type,
suggest_reason,
@@ -85,16 +85,16 @@ pub fn build_tool_suggestion_elicitation_request(
}
}
pub fn all_suggested_connectors_picked_up(
pub fn all_requested_connectors_picked_up(
expected_connector_ids: &[String],
accessible_connectors: &[AppInfo],
) -> bool {
expected_connector_ids.iter().all(|connector_id| {
verified_connector_suggestion_completed(connector_id, accessible_connectors)
verified_connector_install_completed(connector_id, accessible_connectors)
})
}
pub fn verified_connector_suggestion_completed(
pub fn verified_connector_install_completed(
tool_id: &str,
accessible_connectors: &[AppInfo],
) -> bool {
@@ -104,17 +104,17 @@ pub fn verified_connector_suggestion_completed(
.is_some_and(|connector| connector.is_accessible)
}
fn build_tool_suggestion_meta<'a>(
fn build_request_plugin_install_meta<'a>(
tool_type: DiscoverableToolType,
action_type: DiscoverableToolAction,
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: Option<&'a str>,
) -> ToolSuggestMeta<'a> {
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
) -> RequestPluginInstallMeta<'a> {
RequestPluginInstallMeta {
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE,
tool_type,
suggest_type: action_type,
suggest_reason,
@@ -125,5 +125,5 @@ fn build_tool_suggestion_meta<'a>(
}
#[cfg(test)]
#[path = "tool_suggest_tests.rs"]
#[path = "request_plugin_install_tests.rs"]
mod tests;

View File

@@ -4,8 +4,8 @@ use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
let args = ToolSuggestArgs {
fn build_request_plugin_install_elicitation_request_uses_expected_shape() {
let args = RequestPluginInstallArgs {
tool_type: DiscoverableToolType::Connector,
action_type: DiscoverableToolAction::Install,
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
@@ -30,7 +30,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
plugin_display_names: Vec::new(),
}));
let request = build_tool_suggestion_elicitation_request(
let request = build_request_plugin_install_elicitation_request(
"codex-apps",
"thread-1".to_string(),
"turn-1".to_string(),
@@ -46,9 +46,9 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
turn_id: Some("turn-1".to_string()),
server_name: "codex-apps".to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
meta: Some(json!(RequestPluginInstallMeta {
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
@@ -71,8 +71,8 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
}
#[test]
fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
let args = ToolSuggestArgs {
fn build_request_plugin_install_elicitation_request_for_plugin_omits_install_url() {
let args = RequestPluginInstallArgs {
tool_type: DiscoverableToolType::Plugin,
action_type: DiscoverableToolAction::Install,
tool_id: "sample@openai-curated".to_string(),
@@ -87,7 +87,7 @@ fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
app_connector_ids: vec!["connector_calendar".to_string()],
}));
let request = build_tool_suggestion_elicitation_request(
let request = build_request_plugin_install_elicitation_request(
"codex-apps",
"thread-1".to_string(),
"turn-1".to_string(),
@@ -103,9 +103,9 @@ fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
turn_id: Some("turn-1".to_string()),
server_name: "codex-apps".to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
meta: Some(json!(RequestPluginInstallMeta {
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE,
tool_type: DiscoverableToolType::Plugin,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Use the sample plugin's skills and MCP server",
@@ -126,8 +126,8 @@ fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
}
#[test]
fn build_tool_suggestion_meta_uses_expected_shape() {
let meta = build_tool_suggestion_meta(
fn build_request_plugin_install_meta_uses_expected_shape() {
let meta = build_request_plugin_install_meta(
DiscoverableToolType::Connector,
DiscoverableToolAction::Install,
"Find and reference emails from your inbox",
@@ -138,9 +138,9 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
assert_eq!(
meta,
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
RequestPluginInstallMeta {
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Find and reference emails from your inbox",
@@ -154,7 +154,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
}
#[test]
fn verified_connector_suggestion_completed_requires_accessible_connector() {
fn verified_connector_install_completed_requires_accessible_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
@@ -171,18 +171,18 @@ fn verified_connector_suggestion_completed_requires_accessible_connector() {
plugin_display_names: Vec::new(),
}];
assert!(verified_connector_suggestion_completed(
assert!(verified_connector_install_completed(
"calendar",
&accessible_connectors,
));
assert!(!verified_connector_suggestion_completed(
assert!(!verified_connector_install_completed(
"gmail",
&accessible_connectors,
));
}
#[test]
fn all_suggested_connectors_picked_up_requires_every_expected_connector() {
fn all_requested_connectors_picked_up_requires_every_expected_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
@@ -199,11 +199,11 @@ fn all_suggested_connectors_picked_up_requires_every_expected_connector() {
plugin_display_names: Vec::new(),
}];
assert!(all_suggested_connectors_picked_up(
assert!(all_requested_connectors_picked_up(
&["calendar".to_string()],
&accessible_connectors,
));
assert!(!all_suggested_connectors_picked_up(
assert!(!all_requested_connectors_picked_up(
&["calendar".to_string(), "gmail".to_string()],
&accessible_connectors,
));

View File

@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
const TUI_CLIENT_NAME: &str = "codex-tui";
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8;
pub const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolSearchSourceInfo {
@@ -111,7 +111,7 @@ impl From<DiscoverablePluginInfo> for DiscoverableTool {
}
}
pub fn filter_tool_suggest_discoverable_tools_for_client(
pub fn filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools: Vec<DiscoverableTool>,
app_server_client_name: Option<&str>,
) -> Vec<DiscoverableTool> {
@@ -136,7 +136,7 @@ pub struct DiscoverablePluginInfo {
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolSuggestEntry {
pub struct RequestPluginInstallEntry {
pub id: String,
pub name: String,
pub description: Option<String>,
@@ -271,7 +271,9 @@ pub fn collect_tool_search_source_infos<'a>(
.collect()
}
pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> ToolSpec {
pub fn create_request_plugin_install_tool(
discoverable_tools: &[RequestPluginInstallEntry],
) -> ToolSpec {
let properties = BTreeMap::from([
(
"tool_type".to_string(),
@@ -291,7 +293,7 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool
(
"suggest_reason".to_string(),
JsonSchema::string(Some(
"Concise one-line user-facing reason why this tool can help with the current request."
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
.to_string(),
)),
),
@@ -299,11 +301,11 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool
let discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = format!(
"# Tool suggestion discovery\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly wants a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list.\n\nDo not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful. The user's intent must clearly match one listed tool.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If `{TOOL_SEARCH_TOOL_NAME}` is available, call `{TOOL_SEARCH_TOOL_NAME}` before calling `{TOOL_SUGGEST_TOOL_NAME}`. Do not use tool suggestion if the needed tool is already available, found through `{TOOL_SEARCH_TOOL_NAME}`, or callable after discovery.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one tool clearly fits, call `{TOOL_SUGGEST_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request\n5. After the suggestion flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available tool\n - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
"# Request plugin/connector install\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
);
ToolSpec::Function(ResponsesApiTool {
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
@@ -321,13 +323,13 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool
})
}
pub fn collect_tool_suggest_entries(
pub fn collect_request_plugin_install_entries(
discoverable_tools: &[DiscoverableTool],
) -> Vec<ToolSuggestEntry> {
) -> Vec<RequestPluginInstallEntry> {
discoverable_tools
.iter()
.map(|tool| match tool {
DiscoverableTool::Connector(connector) => ToolSuggestEntry {
DiscoverableTool::Connector(connector) => RequestPluginInstallEntry {
id: connector.id.clone(),
name: connector.name.clone(),
description: connector.description.clone(),
@@ -336,7 +338,7 @@ pub fn collect_tool_suggest_entries(
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
},
DiscoverableTool::Plugin(plugin) => ToolSuggestEntry {
DiscoverableTool::Plugin(plugin) => RequestPluginInstallEntry {
id: plugin.id.clone(),
name: plugin.name.clone(),
description: plugin.description.clone(),
@@ -349,7 +351,7 @@ pub fn collect_tool_suggest_entries(
.collect()
}
fn format_discoverable_tools(discoverable_tools: &[ToolSuggestEntry]) -> String {
fn format_discoverable_tools(discoverable_tools: &[RequestPluginInstallEntry]) -> String {
let mut discoverable_tools = discoverable_tools.to_vec();
discoverable_tools.sort_by(|left, right| {
left.name
@@ -373,7 +375,7 @@ fn format_discoverable_tools(discoverable_tools: &[ToolSuggestEntry]) -> String
.join("\n")
}
fn tool_description_or_fallback(tool: &ToolSuggestEntry) -> String {
fn tool_description_or_fallback(tool: &RequestPluginInstallEntry) -> String {
if let Some(description) = tool
.description
.as_deref()
@@ -389,7 +391,7 @@ fn tool_description_or_fallback(tool: &ToolSuggestEntry) -> String {
}
}
fn plugin_summary(tool: &ToolSuggestEntry) -> String {
fn plugin_summary(tool: &RequestPluginInstallEntry) -> String {
let mut details = Vec::new();
if tool.has_skills {
details.push("skills".to_string());

View File

@@ -49,36 +49,36 @@ fn create_tool_search_tool_deduplicates_and_renders_enabled_sources() {
}
#[test]
fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
fn create_request_plugin_install_tool_uses_plugin_summary_fallback() {
let expected_description = concat!(
"# Tool suggestion discovery\n\n",
"# Request plugin/connector install\n\n",
"Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\n",
"Use this ONLY when all of the following are true:\n",
"- The user explicitly wants a specific plugin or connector that is not already available in the current context or active `tools` list.\n",
"- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n",
"- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n",
"- The tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list.\n\n",
"Do not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful. The user's intent must clearly match one listed tool.\n\n",
"- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\n",
"Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\n",
"Known plugins/connectors available to install:\n",
"- GitHub (id: `github`, type: plugin, action: install): skills; MCP servers: github-mcp; app connectors: github-app\n",
"- Slack (id: `slack@openai-curated`, type: connector, action: install): No description provided.\n\n",
"Workflow:\n\n",
"1. Check the current context and active `tools` list first. If `tool_search` is available, call `tool_search` before calling `tool_suggest`. Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery.\n",
"1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.\n",
"2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n",
"3. If we found both connectors and plugins to suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n",
"4. If one tool clearly fits, call `tool_suggest` with:\n",
"3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n",
"4. If one plugin or connector clearly fits, call `request_plugin_install` with:\n",
" - `tool_type`: `connector` or `plugin`\n",
" - `action_type`: `install`\n",
" - `tool_id`: exact id from the known plugin/connector list above\n",
" - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request\n",
"5. After the suggestion flow completes:\n",
" - if the user finished the install flow, continue by searching again or using the newly available tool\n",
" - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.\n\n",
" - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n",
"5. After the request flow completes:\n",
" - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n",
" - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\n",
"IMPORTANT: DO NOT call this tool in parallel with other tools.",
);
assert_eq!(
create_tool_suggest_tool(&[
ToolSuggestEntry {
create_request_plugin_install_tool(&[
RequestPluginInstallEntry {
id: "slack@openai-curated".to_string(),
name: "Slack".to_string(),
description: None,
@@ -87,7 +87,7 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
},
ToolSuggestEntry {
RequestPluginInstallEntry {
id: "github".to_string(),
name: "GitHub".to_string(),
description: None,
@@ -98,7 +98,7 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
},
]),
ToolSpec::Function(ResponsesApiTool {
name: "tool_suggest".to_string(),
name: "request_plugin_install".to_string(),
description: expected_description.to_string(),
strict: false,
defer_loading: None,
@@ -113,7 +113,7 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
(
"suggest_reason".to_string(),
JsonSchema::string(Some(
"Concise one-line user-facing reason why this tool can help with the current request."
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
.to_string(),
),),
),
@@ -157,7 +157,7 @@ fn discoverable_tool_enums_use_expected_wire_names() {
}
#[test]
fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() {
fn filter_request_plugin_install_discoverable_tools_for_codex_tui_omits_plugins() {
let discoverable_tools = vec![
DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
@@ -185,7 +185,10 @@ fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() {
];
assert_eq!(
filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),),
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
Some("codex-tui"),
),
vec![DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
name: "Google Calendar".to_string(),

View File

@@ -1,4 +1,5 @@
use crate::CommandToolOptions;
use crate::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use crate::REQUEST_USER_INPUT_TOOL_NAME;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
@@ -6,7 +7,6 @@ use crate::ShellToolOptions;
use crate::SpawnAgentToolOptions;
use crate::TOOL_SEARCH_DEFAULT_LIMIT;
use crate::TOOL_SEARCH_TOOL_NAME;
use crate::TOOL_SUGGEST_TOOL_NAME;
use crate::ToolHandlerKind;
use crate::ToolName;
use crate::ToolRegistryPlan;
@@ -19,8 +19,8 @@ use crate::ViewImageToolOptions;
use crate::WebSearchToolOptions;
use crate::coalesce_loadable_tool_specs;
use crate::collect_code_mode_exec_prompt_tool_definitions;
use crate::collect_request_plugin_install_entries;
use crate::collect_tool_search_source_infos;
use crate::collect_tool_suggest_entries;
use crate::create_apply_patch_freeform_tool;
use crate::create_apply_patch_json_tool;
use crate::create_close_agent_tool_v1;
@@ -39,6 +39,7 @@ use crate::create_local_shell_tool;
use crate::create_read_mcp_resource_tool;
use crate::create_report_agent_job_result_tool;
use crate::create_request_permissions_tool;
use crate::create_request_plugin_install_tool;
use crate::create_request_user_input_tool;
use crate::create_resume_agent_tool;
use crate::create_send_input_tool_v1;
@@ -50,7 +51,6 @@ use crate::create_spawn_agent_tool_v2;
use crate::create_spawn_agents_on_csv_tool;
use crate::create_test_sync_tool;
use crate::create_tool_search_tool;
use crate::create_tool_suggest_tool;
use crate::create_update_goal_tool;
use crate::create_update_plan_tool;
use crate::create_view_image_tool;
@@ -312,11 +312,16 @@ pub fn build_tool_registry_plan(
params.discoverable_tools.filter(|tools| !tools.is_empty())
{
plan.push_spec(
create_tool_suggest_tool(&collect_tool_suggest_entries(discoverable_tools)),
create_request_plugin_install_tool(&collect_request_plugin_install_entries(
discoverable_tools,
)),
/*supports_parallel_tool_calls*/ true,
/*code_mode_enabled*/ false,
);
plan.register_handler(TOOL_SUGGEST_TOOL_NAME, ToolHandlerKind::ToolSuggest);
plan.register_handler(
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
ToolHandlerKind::RequestPluginInstall,
);
}
if config.has_environment

View File

@@ -1692,7 +1692,7 @@ fn search_tool_keeps_plain_deferred_dynamic_tools_when_namespace_tools_are_disab
}
#[test]
fn tool_suggest_is_not_registered_without_feature_flag() {
fn request_plugin_install_is_not_registered_without_feature_flag() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::ToolSearch);
@@ -1725,12 +1725,12 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
assert!(
!tools
.iter()
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME)
.any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME)
);
}
#[test]
fn tool_suggest_can_be_registered_without_search_tool() {
fn request_plugin_install_can_be_registered_without_search_tool() {
let model_info = ModelInfo {
supports_search_tool: false,
..search_capable_model_info()
@@ -1762,12 +1762,13 @@ fn tool_suggest_can_be_registered_without_search_tool() {
&[],
);
assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]);
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
assert!(tool_suggest.supports_parallel_tool_calls);
assert_contains_tool_names(&tools, &[REQUEST_PLUGIN_INSTALL_TOOL_NAME]);
let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME);
assert!(request_plugin_install.supports_parallel_tool_calls);
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else {
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &request_plugin_install.spec
else {
panic!("expected function tool");
};
assert!(description.contains(
@@ -1779,7 +1780,7 @@ fn tool_suggest_can_be_registered_without_search_tool() {
}
#[test]
fn tool_suggest_description_lists_discoverable_tools() {
fn request_plugin_install_description_lists_discoverable_tools() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
@@ -1827,16 +1828,16 @@ fn tool_suggest_description_lists_discoverable_tools() {
&[],
);
assert!(handlers.contains(&ToolHandlerSpec {
name: ToolName::plain(TOOL_SUGGEST_TOOL_NAME),
kind: ToolHandlerKind::ToolSuggest,
name: ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME),
kind: ToolHandlerKind::RequestPluginInstall,
}));
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
..
}) = &tool_suggest.spec
}) = &request_plugin_install.spec
else {
panic!("expected function tool");
};
@@ -1855,30 +1856,27 @@ fn tool_suggest_description_lists_discoverable_tools() {
);
assert!(
description.contains(
"The user explicitly wants a specific plugin or connector that is not already available in the current context or active `tools` list."
"The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list."
)
);
assert!(description.contains(
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
));
assert!(description.contains(
"The tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list."
"The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list."
));
assert!(description.contains(
"Do not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful."
"Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful."
));
assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools."));
assert!(description.contains(
"Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery."
));
assert!(description.contains(
"If `tool_search` is available, call `tool_search` before calling `tool_suggest`."
"If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool."
));
assert!(!description.contains("targeted lookup"));
assert!(!description.contains("broad or speculative searches"));
assert!(description.contains("Only proceed when one listed plugin or connector exactly fits."));
assert!(description.contains(
"If we found both connectors and plugins to suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not."
"If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not."
));
assert!(!description.contains("{{discoverable_tools}}"));
assert!(!description.contains("tool_search fails to find a good match"));

View File

@@ -35,7 +35,7 @@ pub enum ToolHandlerKind {
SpawnAgentV2,
TestSync,
ToolSearch,
ToolSuggest,
RequestPluginInstall,
UnifiedExec,
ViewImage,
WaitAgentV1,