add search_plugins tool for remote plugin catalog

This commit is contained in:
Alex Daley
2026-06-01 10:38:31 -04:00
parent 3b7334d099
commit b62c204993
7 changed files with 377 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ use std::time::Duration;
use url::Url;
mod remote_installed_plugin_sync;
mod search;
mod share;
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
@@ -30,6 +31,9 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
pub use search::RemotePluginSearchPlugin;
pub use search::RemotePluginSearchResult;
pub use search::search_global_remote_plugins;
pub use share::RemotePluginShareAccessPolicy;
pub use share::RemotePluginShareDiscoverability;
pub use share::RemotePluginSharePrincipal;

View File

@@ -0,0 +1,164 @@
use super::RemotePluginCatalogError;
use super::RemotePluginListResponse;
use super::RemotePluginScope;
use super::RemotePluginServiceConfig;
use super::authenticated_request;
use super::ensure_chatgpt_auth;
use super::send_and_decode;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RemotePluginSearchPlugin {
pub remote_plugin_id: String,
pub name: String,
pub display_name: String,
pub short_description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RemotePluginSearchResult {
pub plugins: Vec<RemotePluginSearchPlugin>,
}
pub async fn search_global_remote_plugins(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
q: &str,
) -> Result<RemotePluginSearchResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/discover");
let client = build_reqwest_client();
let request = authenticated_request(client.get(&url), auth)?
.query(&[("q", q), ("scope", RemotePluginScope::Global.api_value())]);
let response: RemotePluginListResponse = send_and_decode(request, &url).await?;
Ok(RemotePluginSearchResult {
plugins: response
.plugins
.into_iter()
.map(|plugin| RemotePluginSearchPlugin {
remote_plugin_id: plugin.id,
name: plugin.name,
display_name: plugin.release.display_name,
short_description: plugin.release.interface.short_description,
})
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use reqwest::StatusCode;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
use wiremock::matchers::query_param_is_missing;
fn test_config(server: &MockServer) -> RemotePluginServiceConfig {
RemotePluginServiceConfig {
chatgpt_base_url: format!("{}/backend-api", server.uri()),
}
}
fn test_auth() -> CodexAuth {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
}
#[tokio::test]
async fn search_global_remote_plugins_returns_compact_first_page() {
let server = MockServer::start().await;
let q = "plugin_name:slack AND tool_name:(search OR messages)";
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/discover"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(query_param("q", q))
.and(query_param("scope", "GLOBAL"))
.and(query_param_is_missing("includeInstalled"))
.and(query_param_is_missing("includeDownloadUrls"))
.and(query_param_is_missing("limit"))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [{
"id": "plugin_123",
"name": "slack",
"scope": "GLOBAL",
"discoverability": "LISTED",
"status": "ENABLED",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.0.0",
"display_name": "Slack",
"description": "Search and read Slack messages",
"interface": {
"short_description": "Search and read Slack messages"
},
"skills": []
}
}],
"pagination": {
"limit": 50,
"next_page_token": "unused-next-page"
}
})))
.mount(&server)
.await;
assert_eq!(
search_global_remote_plugins(&test_config(&server), Some(&test_auth()), q)
.await
.unwrap(),
RemotePluginSearchResult {
plugins: vec![RemotePluginSearchPlugin {
remote_plugin_id: "plugin_123".to_string(),
name: "slack".to_string(),
display_name: "Slack".to_string(),
short_description: Some("Search and read Slack messages".to_string()),
}],
}
);
}
#[tokio::test]
async fn search_global_remote_plugins_preserves_backend_error_details() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/discover"))
.and(query_param("q", "tool_name:(search OR)"))
.and(query_param("scope", "GLOBAL"))
.respond_with(
ResponseTemplate::new(422).set_body_string(r#"{"detail":"Expected query term"}"#),
)
.mount(&server)
.await;
let err = search_global_remote_plugins(
&test_config(&server),
Some(&test_auth()),
"tool_name:(search OR)",
)
.await
.unwrap_err();
let RemotePluginCatalogError::UnexpectedStatus { url, status, body } = err else {
panic!("expected unexpected status error");
};
assert_eq!(
(url, status, body),
(
format!("{}/backend-api/ps/plugins/discover", server.uri()),
StatusCode::UNPROCESSABLE_ENTITY,
r#"{"detail":"Expected query term"}"#.to_string(),
)
);
}
}

View File

@@ -22,6 +22,8 @@ mod request_plugin_install;
pub(crate) mod request_plugin_install_spec;
mod request_user_input;
pub(crate) mod request_user_input_spec;
mod search_plugins;
pub(crate) mod search_plugins_spec;
mod shell;
pub(crate) mod shell_spec;
mod test_sync;
@@ -65,6 +67,7 @@ pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use search_plugins::SearchPluginsHandler;
pub use shell::ShellCommandHandler;
pub(crate) use shell::ShellCommandHandlerOptions;
pub use test_sync::TestSyncHandler;

View File

@@ -0,0 +1,88 @@
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::search_global_remote_plugins;
use codex_tools::JsonToolOutput;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::search_plugins_spec::SEARCH_PLUGINS_TOOL_NAME;
use crate::tools::handlers::search_plugins_spec::create_search_plugins_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
pub struct SearchPluginsHandler;
#[derive(Debug, Deserialize)]
struct SearchPluginsArgs {
q: String,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for SearchPluginsHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(SEARCH_PLUGINS_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_search_plugins_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
true
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"{SEARCH_PLUGINS_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: SearchPluginsArgs = parse_arguments(&arguments)?;
let auth = session.services.auth_manager.auth().await;
let result = search_global_remote_plugins(
&RemotePluginServiceConfig {
chatgpt_base_url: turn.config.chatgpt_base_url.clone(),
},
auth.as_ref(),
&args.q,
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("{SEARCH_PLUGINS_TOOL_NAME} failed: {err}"))
})?;
let value = serde_json::to_value(result).map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize {SEARCH_PLUGINS_TOOL_NAME} response: {err}"
))
})?;
Ok(Box::new(JsonToolOutput::new(value)))
}
}
impl CoreToolRuntime for SearchPluginsHandler {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_plugins_supports_parallel_calls() {
assert!(SearchPluginsHandler.supports_parallel_tool_calls());
}
}

View File

@@ -0,0 +1,93 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) const SEARCH_PLUGINS_TOOL_NAME: &str = "search_plugins";
pub(crate) fn create_search_plugins_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"q".to_string(),
JsonSchema::string(Some(
"Plugin discovery query using the language documented in this tool description."
.to_string(),
)),
)]);
let description = "# Search global plugins
Search the global plugin catalog. Installed plugins are excluded. This returns the first page only.
The `q` argument uses a small KQL/Lucene-style filter language. Unfielded terms search plugin names, plugin descriptions, and callable app action names. To search a specific field, use `plugin_name:`, `plugin_description:`, or `tool_name:`. `tool_name` refers to callable app action names, not plugin skill names.
Matching is case-insensitive substring matching. Double-quoted phrases match contiguous substrings including spaces. Parentheses group expressions. Use `AND`, `OR`, and unary `NOT`; adjacent expressions are an implicit `AND`. Field scopes can apply to groups, such as `tool_name:(search OR messages)`.
Examples: `slack`, `plugin_name:slack`, `tool_name:\"search messages\"`, `plugin_name:slack AND tool_name:(search OR messages)`, `NOT tool_name:search`.
Wildcards, fuzziness, regex, ranges, scoring, and raw SQL are unsupported. Invalid queries return an error."
.to_string();
ToolSpec::Function(ResponsesApiTool {
name: SEARCH_PLUGINS_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["q".to_string()]), Some(false.into())),
output_schema: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn create_search_plugins_tool_uses_q_only_schema_and_documents_query_language() {
let ToolSpec::Function(ResponsesApiTool {
name,
description,
strict,
defer_loading,
parameters,
output_schema,
}) = create_search_plugins_tool()
else {
panic!("expected function tool");
};
assert_eq!(name, SEARCH_PLUGINS_TOOL_NAME);
assert!(!strict);
assert_eq!(defer_loading, None);
assert_eq!(
parameters,
JsonSchema::object(
BTreeMap::from([(
"q".to_string(),
JsonSchema::string(Some(
"Plugin discovery query using the language documented in this tool description."
.to_string(),
)),
)]),
Some(vec!["q".to_string()]),
Some(false.into()),
)
);
assert_eq!(output_schema, None);
for expected in [
"plugin_name:",
"plugin_description:",
"tool_name:",
"case-insensitive substring",
"Double-quoted phrases",
"Parentheses",
"`AND`, `OR`, and unary `NOT`",
"implicit `AND`",
"tool_name:(search OR messages)",
"Wildcards, fuzziness, regex, ranges, scoring, and raw SQL are unsupported",
] {
assert!(
description.contains(expected),
"tool description should mention {expected:?}"
);
}
}
}

View File

@@ -18,6 +18,7 @@ use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SearchPluginsHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::TestSyncHandler;
@@ -610,6 +611,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
planned_tools.add(RequestPermissionsHandler);
}
if features.enabled(Feature::Plugins) && features.enabled(Feature::RemotePlugin) {
planned_tools.add(SearchPluginsHandler);
}
if tool_suggest_enabled(turn_context)
&& let Some(discoverable_tools) =
context.discoverable_tools.filter(|tools| !tools.is_empty())

View File

@@ -643,6 +643,26 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable
]);
}
#[tokio::test]
async fn search_plugins_requires_plugins_and_remote_plugin_features() {
for disabled_feature in [Feature::Plugins, Feature::RemotePlugin] {
let plan = probe(|turn| {
set_features(turn, &[Feature::Plugins, Feature::RemotePlugin]);
set_feature(turn, disabled_feature, /*enabled*/ false);
})
.await;
plan.assert_visible_lacks(&["search_plugins"]);
plan.assert_registered_lacks(&["search_plugins"]);
}
let enabled = probe(|turn| {
set_features(turn, &[Feature::Plugins, Feature::RemotePlugin]);
})
.await;
enabled.assert_visible_contains(&["search_plugins"]);
enabled.assert_registered_contains(&["search_plugins"]);
}
#[tokio::test]
async fn install_suggestion_tools_stay_visible_without_tool_search() {
let plan = probe_with(