codex-tools: extract discovery tool specs

This commit is contained in:
Michael Bolin
2026-03-29 14:34:50 -07:00
parent af568afdd5
commit 20c7b1d854
9 changed files with 460 additions and 279 deletions

View File

@@ -1,42 +1,9 @@
use crate::plugins::PluginCapabilitySummary;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
use codex_tools::DiscoverableToolType;
const TUI_CLIENT_NAME: &str = "codex-tui";
#[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>),
@@ -65,13 +32,6 @@ impl DiscoverableTool {
}
}
pub(crate) fn description(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.description.as_deref(),
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
pub(crate) fn install_url(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.install_url.as_deref(),

View File

@@ -8,6 +8,8 @@ use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_rmcp_client::ElicitationAction;
use codex_tools::DiscoverableToolAction;
use codex_tools::DiscoverableToolType;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde::Serialize;
@@ -21,8 +23,6 @@ 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::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;

View File

@@ -8,6 +8,8 @@ use crate::plugins::test_support::write_plugins_feature_config;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use codex_app_server_protocol::AppInfo;
use codex_tools::DiscoverableToolAction;
use codex_tools::DiscoverableToolType;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;

View File

@@ -7,10 +7,7 @@ use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
use crate::tools::code_mode::WAIT_TOOL_NAME;
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;
@@ -41,9 +38,11 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::CommandToolOptions;
use codex_tools::ResponsesApiTool;
use codex_tools::DiscoverableToolType;
use codex_tools::ShellToolOptions;
use codex_tools::SpawnAgentToolOptions;
use codex_tools::ToolSearchAppInfo;
use codex_tools::ToolSuggestEntry;
use codex_tools::ViewImageToolOptions;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::augment_tool_spec_for_code_mode;
@@ -71,6 +70,8 @@ use codex_tools::create_spawn_agent_tool_v1;
use codex_tools::create_spawn_agent_tool_v2;
use codex_tools::create_spawn_agents_on_csv_tool;
use codex_tools::create_test_sync_tool;
use codex_tools::create_tool_search_tool;
use codex_tools::create_tool_suggest_tool;
use codex_tools::create_view_image_tool;
use codex_tools::create_wait_agent_tool_v1;
use codex_tools::create_wait_agent_tool_v2;
@@ -80,33 +81,17 @@ use codex_tools::dynamic_tool_to_responses_api_tool;
use codex_tools::mcp_tool_to_responses_api_tool;
use codex_tools::tool_spec_to_code_mode_tool_definition;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_template::Template;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock;
pub type JsonSchema = codex_tools::JsonSchema;
#[cfg(test)]
pub(crate) use codex_tools::mcp_call_tool_result_output_schema;
const TOOL_SEARCH_DESCRIPTION_TEMPLATE_SOURCE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const TOOL_SEARCH_DESCRIPTION_TEMPLATE_KEY: &str = "app_descriptions";
static TOOL_SEARCH_DESCRIPTION_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(TOOL_SEARCH_DESCRIPTION_TEMPLATE_SOURCE)
.unwrap_or_else(|err| panic!("tool_search description template must parse: {err}"))
});
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE_SOURCE: &str =
include_str!("../../templates/search_tool/tool_suggest_description.md");
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE_KEY: &str = "discoverable_tools";
static TOOL_SUGGEST_DESCRIPTION_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(TOOL_SUGGEST_DESCRIPTION_TEMPLATE_SOURCE)
.unwrap_or_else(|err| panic!("tool_suggest description template must parse: {err}"))
});
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
@@ -373,220 +358,6 @@ fn supports_image_generation(model_info: &ModelInfo) -> bool {
model_info.input_modalities.contains(&InputModality::Image)
}
fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for apps tools.".to_string()),
},
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(format!(
"Maximum number of tools to return (defaults to {TOOL_SEARCH_DEFAULT_LIMIT})."
)),
},
),
]);
let mut app_descriptions = BTreeMap::new();
for tool in app_tools.values() {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
continue;
}
let Some(connector_name) = tool
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
else {
continue;
};
let connector_description = tool
.connector_description
.as_deref()
.map(str::trim)
.filter(|connector_description| !connector_description.is_empty())
.map(str::to_string);
app_descriptions
.entry(connector_name.to_string())
.and_modify(|existing: &mut Option<String>| {
if existing.is_none() {
*existing = connector_description.clone();
}
})
.or_insert(connector_description);
}
let app_descriptions = if app_descriptions.is_empty() {
"None currently enabled.".to_string()
} else {
app_descriptions
.into_iter()
.map(
|(connector_name, connector_description)| match connector_description {
Some(connector_description) => {
format!("- {connector_name}: {connector_description}")
}
None => format!("- {connector_name}"),
},
)
.collect::<Vec<_>>()
.join("\n")
};
let description = TOOL_SEARCH_DESCRIPTION_TEMPLATE
.render([(
TOOL_SEARCH_DESCRIPTION_TEMPLATE_KEY,
app_descriptions.as_str(),
)])
.unwrap_or_else(|err| panic!("tool_search description template must render: {err}"));
ToolSpec::ToolSearch {
execution: "client".to_string(),
description,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
}
}
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 discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE
.render([(
TOOL_SUGGEST_DESCRIPTION_TEMPLATE_KEY,
discoverable_tools.as_str(),
)])
.unwrap_or_else(|err| panic!("tool_suggest description template must render: {err}"));
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::Install,
};
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("; ")
}
}
/// TODO(dylan): deprecate once we get rid of json tool
#[derive(Serialize, Deserialize)]
pub(crate) struct ApplyPatchToolArgs {
@@ -861,7 +632,10 @@ pub(crate) fn build_specs_with_discoverable_tools(
let search_tool_handler = Arc::new(ToolSearchHandler::new(app_tools.clone()));
push_tool_spec(
&mut builder,
create_tool_search_tool(&app_tools),
create_tool_search_tool(
&tool_search_app_infos(&app_tools),
TOOL_SEARCH_DEFAULT_LIMIT,
),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
@@ -881,7 +655,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
.filter(|tools| !tools.is_empty())
{
builder.push_spec_with_parallel_support(
create_tool_suggest_tool(discoverable_tools),
create_tool_suggest_tool(&tool_suggest_entries(discoverable_tools)),
/*supports_parallel_tool_calls*/ true,
);
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
@@ -1167,6 +941,54 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder
}
fn tool_search_app_infos(app_tools: &HashMap<String, ToolInfo>) -> Vec<ToolSearchAppInfo> {
app_tools
.values()
.filter(|tool| tool.server_name == CODEX_APPS_MCP_SERVER_NAME)
.filter_map(|tool| {
let name = tool
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())?
.to_string();
let description = tool
.connector_description
.as_deref()
.map(str::trim)
.filter(|connector_description| !connector_description.is_empty())
.map(str::to_string);
Some(ToolSearchAppInfo { name, description })
})
.collect()
}
fn tool_suggest_entries(discoverable_tools: &[DiscoverableTool]) -> Vec<ToolSuggestEntry> {
discoverable_tools
.iter()
.map(|tool| match tool {
DiscoverableTool::Connector(connector) => ToolSuggestEntry {
id: connector.id.clone(),
name: connector.name.clone(),
description: connector.description.clone(),
tool_type: DiscoverableToolType::Connector,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
},
DiscoverableTool::Plugin(plugin) => ToolSuggestEntry {
id: plugin.id.clone(),
name: plugin.name.clone(),
description: plugin.description.clone(),
tool_type: DiscoverableToolType::Plugin,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names.clone(),
app_connector_ids: plugin.app_connector_ids.clone(),
},
})
.collect()
}
#[cfg(test)]
#[path = "spec_tests.rs"]
mod tests;

View File

@@ -4,6 +4,7 @@ use crate::models_manager::model_info::with_config_overrides;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::ToolRouter;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::router::ToolRouterParams;
use codex_app_server_protocol::AppInfo;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
@@ -14,6 +15,7 @@ use codex_tools::AdditionalProperties;
use codex_tools::CommandToolOptions;
use codex_tools::ConfiguredToolSpec;
use codex_tools::FreeformTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ResponsesApiWebSearchFilters;
use codex_tools::ResponsesApiWebSearchUserLocation;
use codex_tools::SpawnAgentToolOptions;

View File

@@ -27,6 +27,8 @@ schema and Responses API tool primitives that no longer need to live in
- local host tool spec builders for shell/exec/request-permissions/view-image
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
`request_user_input`, and CSV fanout/reporting
- tool discovery and suggestion models / `ToolSpec` builders for
`tool_search` and `tool_suggest`
- `parse_tool_input_schema()`
- `parse_dynamic_tool()`
- `parse_mcp_tool()`

View File

@@ -13,6 +13,7 @@ mod mcp_tool;
mod request_user_input_tool;
mod responses_api;
mod tool_definition;
mod tool_discovery;
mod tool_spec;
mod utility_tool;
mod view_image;
@@ -66,6 +67,12 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_definition::ToolDefinition;
pub use tool_discovery::DiscoverableToolAction;
pub use tool_discovery::DiscoverableToolType;
pub use tool_discovery::ToolSearchAppInfo;
pub use tool_discovery::ToolSuggestEntry;
pub use tool_discovery::create_tool_search_tool;
pub use tool_discovery::create_tool_suggest_tool;
pub use tool_spec::ConfiguredToolSpec;
pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;

View File

@@ -0,0 +1,237 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolSearchAppInfo {
pub name: String,
pub description: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DiscoverableToolType {
Connector,
Plugin,
}
impl DiscoverableToolType {
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 enum DiscoverableToolAction {
Install,
Enable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolSuggestEntry {
pub id: String,
pub name: String,
pub description: Option<String>,
pub tool_type: DiscoverableToolType,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<String>,
}
pub fn create_tool_search_tool(app_tools: &[ToolSearchAppInfo], default_limit: usize) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for apps tools.".to_string()),
},
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(format!(
"Maximum number of tools to return (defaults to {default_limit})."
)),
},
),
]);
let mut app_descriptions = BTreeMap::new();
for app_tool in app_tools {
app_descriptions
.entry(app_tool.name.clone())
.and_modify(|existing: &mut Option<String>| {
if existing.is_none() {
*existing = app_tool.description.clone();
}
})
.or_insert(app_tool.description.clone());
}
let app_descriptions = if app_descriptions.is_empty() {
"None currently enabled.".to_string()
} else {
app_descriptions
.into_iter()
.map(|(name, description)| match description {
Some(description) => format!("- {name}: {description}"),
None => format!("- {name}"),
})
.collect::<Vec<_>>()
.join("\n")
};
let description = format!(
"# Apps (Connectors) tool discovery\n\nSearches over apps/connectors tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to all the tools of the following apps/connectors:\n{app_descriptions}\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools and load them for the apps mentioned above. For the apps mentioned above, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery."
);
ToolSpec::ToolSearch {
execution: "client".to_string(),
description,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
}
}
pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> ToolSpec {
let discoverable_tool_ids = discoverable_tools
.iter()
.map(|tool| tool.id.as_str())
.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 discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = format!(
"# Tool suggestion discovery\n\nSuggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin, when the user clearly wants a capability that is not currently available in the active `tools` list.\n\nUse this ONLY when:\n- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means.\n- For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely.\n- For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself. Do not suggest a plugin just because one of its connectors or capabilities seems relevant.\n\nTool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.\n\nDiscoverable tools:\n{discoverable_tools}\n\nWorkflow:\n\n1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent.\n2. Match the user's request against the discoverable tools list above. Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard.\n3. If one tool clearly fits, call `tool_suggest` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install` or `enable`\n - `tool_id`: exact id from the discoverable tools list above\n - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request\n4. After the suggestion flow completes:\n - if the user finished the install or enable 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."
);
ToolSpec::Function(ResponsesApiTool {
name: "tool_suggest".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: &[ToolSuggestEntry]) -> 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_or_fallback(&tool);
format!(
"- {} (id: `{}`, type: {}, action: install): {}",
tool.name,
tool.id,
tool.tool_type.as_str(),
description
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn tool_description_or_fallback(tool: &ToolSuggestEntry) -> String {
if let Some(description) = tool
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
return description.to_string();
}
match tool.tool_type {
DiscoverableToolType::Connector => "No description provided.".to_string(),
DiscoverableToolType::Plugin => plugin_summary(tool),
}
}
fn plugin_summary(tool: &ToolSuggestEntry) -> String {
let mut details = Vec::new();
if tool.has_skills {
details.push("skills".to_string());
}
if !tool.mcp_server_names.is_empty() {
details.push(format!("MCP servers: {}", tool.mcp_server_names.join(", ")));
}
if !tool.app_connector_ids.is_empty() {
details.push(format!(
"app connectors: {}",
tool.app_connector_ids.join(", ")
));
}
if details.is_empty() {
"No description provided.".to_string()
} else {
details.join("; ")
}
}
#[cfg(test)]
#[path = "tool_discovery_tests.rs"]
mod tests;

View File

@@ -0,0 +1,149 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn create_tool_search_tool_deduplicates_and_renders_enabled_apps() {
assert_eq!(
create_tool_search_tool(
&[
ToolSearchAppInfo {
name: "Google Drive".to_string(),
description: Some(
"Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work."
.to_string(),
),
},
ToolSearchAppInfo {
name: "Google Drive".to_string(),
description: None,
},
ToolSearchAppInfo {
name: "Slack".to_string(),
description: None,
},
],
/*default_limit*/ 8,
),
ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "# Apps (Connectors) tool discovery\n\nSearches over apps/connectors tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to all the tools of the following apps/connectors:\n- Google Drive: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.\n- Slack\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools and load them for the apps mentioned above. For the apps mentioned above, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery.".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"limit".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tools to return (defaults to 8)."
.to_string(),
),
},
),
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for apps tools.".to_string()),
},
),
]),
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
}
);
}
#[test]
fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
assert_eq!(
create_tool_suggest_tool(&[
ToolSuggestEntry {
id: "slack@openai-curated".to_string(),
name: "Slack".to_string(),
description: None,
tool_type: DiscoverableToolType::Connector,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
},
ToolSuggestEntry {
id: "github".to_string(),
name: "GitHub".to_string(),
description: None,
tool_type: DiscoverableToolType::Plugin,
has_skills: true,
mcp_server_names: vec!["github-mcp".to_string()],
app_connector_ids: vec!["github-app".to_string()],
},
]),
ToolSpec::Function(ResponsesApiTool {
name: "tool_suggest".to_string(),
description: "# Tool suggestion discovery\n\nSuggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin, when the user clearly wants a capability that is not currently available in the active `tools` list.\n\nUse this ONLY when:\n- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means.\n- For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely.\n- For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself. Do not suggest a plugin just because one of its connectors or capabilities seems relevant.\n\nTool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.\n\nDiscoverable tools:\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\nWorkflow:\n\n1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent.\n2. Match the user's request against the discoverable tools list above. Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard.\n3. If one tool clearly fits, call `tool_suggest` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install` or `enable`\n - `tool_id`: exact id from the discoverable tools list above\n - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request\n4. After the suggestion flow completes:\n - if the user finished the install or enable 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.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"action_type".to_string(),
JsonSchema::String {
description: Some(
"Suggested action for the tool. Use \"install\" or \"enable\"."
.to_string(),
),
},
),
(
"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(),
),
},
),
(
"tool_id".to_string(),
JsonSchema::String {
description: Some(
"Connector or plugin id to suggest. Must be one of: slack@openai-curated, github."
.to_string(),
),
},
),
(
"tool_type".to_string(),
JsonSchema::String {
description: Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
),
},
),
]),
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,
})
);
}
#[test]
fn discoverable_tool_enums_use_expected_wire_names() {
assert_eq!(
json!({
"tool_type": DiscoverableToolType::Connector,
"action_type": DiscoverableToolAction::Install,
}),
json!({
"tool_type": "connector",
"action_type": "install",
})
);
}