mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
add search_plugins tool for remote plugin catalog
This commit is contained in:
@@ -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;
|
||||
|
||||
164
codex-rs/core-plugins/src/remote/search.rs
Normal file
164
codex-rs/core-plugins/src/remote/search.rs
Normal 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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
88
codex-rs/core/src/tools/handlers/search_plugins.rs
Normal file
88
codex-rs/core/src/tools/handlers/search_plugins.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
93
codex-rs/core/src/tools/handlers/search_plugins_spec.rs
Normal file
93
codex-rs/core/src/tools/handlers/search_plugins_spec.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user