mirror of
https://github.com/openai/codex.git
synced 2026-04-30 11:21:34 +03: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:
@@ -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