mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[codex] [3/4] Activate endpoint plugin recommendations (#27704)
Summary\n- Await endpoint recommendation selection while constructing each authenticated turn, removing the first-turn cache race.\n- Snapshot and filter endpoint candidates once per turn, then use that same set for the bounded contextual user fragment, tool exposure, and exact install validation.\n- Keep recommendation selection ephemeral: do not persist recommendation state in or gate resumed threads on prior context.\n- Hide the legacy list tool in endpoint mode and preserve legacy discovery unchanged when the endpoint is disabled or unavailable.\n- Keep remote plugin and connector app identities out of model-visible context and attach them only to Codex-owned elicitation metadata.\n\nStack\n- 3/4, based on #28400.\n- Endpoint client and cache: #28399.\n- Generalized suggestion presentation: #28400.\n- Install-schema follow-up: #28403.\n\nValidation\n- \n- \n- \n- \n- Full : 2,649 passed and 88 environment-dependent tests failed because this sandbox cannot write , nest Seatbelt, or locate auxiliary test binaries.
This commit is contained in:
1 parent
587487df9e
commit
a34da3b295
18 files changed
+846
-124
No files matched your search
Generated
+1
@@ -2760,6 +2760,7 @@ dependencies = [
|
|||||||
"codex-otel",
|
"codex-otel",
|
||||||
"codex-plugin",
|
"codex-plugin",
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
|
"codex-tools",
|
||||||
"codex-utils-absolute-path",
|
"codex-utils-absolute-path",
|
||||||
"codex-utils-path-uri",
|
"codex-utils-path-uri",
|
||||||
"codex-utils-plugins",
|
"codex-utils-plugins",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ mod process_exec;
|
|||||||
mod rate_limit_reset_credits;
|
mod rate_limit_reset_credits;
|
||||||
mod rate_limits;
|
mod rate_limits;
|
||||||
mod realtime_conversation;
|
mod realtime_conversation;
|
||||||
|
mod recommended_plugins;
|
||||||
mod remote_control;
|
mod remote_control;
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
mod remote_thread_store;
|
mod remote_thread_store;
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use app_test_support::ChatGptIdTokenClaims;
|
||||||
|
use app_test_support::TestAppServer;
|
||||||
|
use app_test_support::encode_id_token;
|
||||||
|
use app_test_support::to_response;
|
||||||
|
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||||
|
use codex_app_server_protocol::JSONRPCResponse;
|
||||||
|
use codex_app_server_protocol::LoginAccountResponse;
|
||||||
|
use codex_app_server_protocol::RequestId;
|
||||||
|
use codex_app_server_protocol::ThreadStartParams;
|
||||||
|
use codex_app_server_protocol::ThreadStartResponse;
|
||||||
|
use codex_app_server_protocol::TurnStartParams;
|
||||||
|
use codex_app_server_protocol::UserInput;
|
||||||
|
use core_test_support::apps_test_server::AppsTestServer;
|
||||||
|
use core_test_support::responses;
|
||||||
|
use serde_json::Value;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use wiremock::Mock;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use wiremock::matchers::method;
|
||||||
|
use wiremock::matchers::path;
|
||||||
|
use wiremock::matchers::query_param;
|
||||||
|
|
||||||
|
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010";
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> {
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let apps_server = AppsTestServer::mount(&server).await?;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/ps/plugins/suggested"))
|
||||||
|
.and(query_param("scope", "GLOBAL"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.set_delay(Duration::from_millis(250))
|
||||||
|
.set_body_json(json!({
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": [{
|
||||||
|
"id": "plugin_github",
|
||||||
|
"name": "github",
|
||||||
|
"status": "ENABLED",
|
||||||
|
"installation_policy": "AVAILABLE",
|
||||||
|
"release": {"display_name": "GitHub"}
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let response = responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-1"),
|
||||||
|
responses::ev_assistant_message("msg-1", "done"),
|
||||||
|
responses::ev_completed("resp-1"),
|
||||||
|
]);
|
||||||
|
let responses_mock = responses::mount_sse_once(&server, response).await;
|
||||||
|
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
write_mock_responses_config_toml_with_chatgpt_base_url(
|
||||||
|
codex_home.path(),
|
||||||
|
&server.uri(),
|
||||||
|
&apps_server.chatgpt_base_url,
|
||||||
|
)?;
|
||||||
|
let config_path = codex_home.path().join("config.toml");
|
||||||
|
let config = std::fs::read_to_string(&config_path)?;
|
||||||
|
std::fs::write(
|
||||||
|
config_path,
|
||||||
|
format!(
|
||||||
|
"{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let sqlite_home = codex_home.path().to_string_lossy();
|
||||||
|
let mut app_server = TestAppServer::new_without_managed_config_with_env(
|
||||||
|
codex_home.path(),
|
||||||
|
&[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
|
||||||
|
|
||||||
|
let access_token = encode_id_token(
|
||||||
|
&ChatGptIdTokenClaims::new()
|
||||||
|
.email("embedded@example.com")
|
||||||
|
.plan_type("pro")
|
||||||
|
.chatgpt_account_id(WORKSPACE_ID),
|
||||||
|
)?;
|
||||||
|
let login_id = app_server
|
||||||
|
.send_chatgpt_auth_tokens_login_request(
|
||||||
|
access_token,
|
||||||
|
WORKSPACE_ID.to_string(),
|
||||||
|
Some("pro".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let login_response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
app_server.read_stream_until_response_message(RequestId::Integer(login_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(
|
||||||
|
to_response::<LoginAccountResponse>(login_response)?,
|
||||||
|
LoginAccountResponse::ChatgptAuthTokens {}
|
||||||
|
);
|
||||||
|
|
||||||
|
let thread_id = app_server
|
||||||
|
.send_thread_start_request(ThreadStartParams {
|
||||||
|
model: Some("mock-model".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let thread_response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
app_server.read_stream_until_response_message(RequestId::Integer(thread_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let ThreadStartResponse { thread, .. } = to_response(thread_response)?;
|
||||||
|
|
||||||
|
let turn_id = app_server
|
||||||
|
.send_turn_start_request(TurnStartParams {
|
||||||
|
thread_id: thread.id,
|
||||||
|
input: vec![UserInput::Text {
|
||||||
|
text: "suggest a plugin".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let _: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
app_server.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
app_server.read_stream_until_notification_message("turn/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
let requests = responses_mock.requests();
|
||||||
|
let request = requests
|
||||||
|
.iter()
|
||||||
|
.find(|request| {
|
||||||
|
request
|
||||||
|
.message_input_texts("user")
|
||||||
|
.iter()
|
||||||
|
.any(|text| text.contains("suggest a plugin"))
|
||||||
|
})
|
||||||
|
.expect("turn request");
|
||||||
|
let contextual_user_message = request.message_input_texts("user").join("\n");
|
||||||
|
assert!(contextual_user_message.contains("<recommended_plugins>"));
|
||||||
|
assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)"));
|
||||||
|
let body = request.body_json();
|
||||||
|
let tool_names = body
|
||||||
|
.get("tools")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(tool_names.contains(&"request_plugin_install"));
|
||||||
|
assert!(!tool_names.contains(&"list_available_plugins_to_install"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ codex-model-provider = { workspace = true }
|
|||||||
codex-otel = { workspace = true }
|
codex-otel = { workspace = true }
|
||||||
codex-plugin = { workspace = true }
|
codex-plugin = { workspace = true }
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
|
codex-tools = { workspace = true }
|
||||||
codex-utils-absolute-path = { workspace = true }
|
codex-utils-absolute-path = { workspace = true }
|
||||||
codex-utils-path-uri = { workspace = true }
|
codex-utils-path-uri = { workspace = true }
|
||||||
codex-utils-plugins = { workspace = true }
|
codex-utils-plugins = { workspace = true }
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub use manager::PluginReadRequest;
|
|||||||
pub use manager::PluginUninstallError;
|
pub use manager::PluginUninstallError;
|
||||||
pub use manager::PluginsConfigInput;
|
pub use manager::PluginsConfigInput;
|
||||||
pub use manager::PluginsManager;
|
pub use manager::PluginsManager;
|
||||||
|
pub use manager::RecommendedPluginCandidatesInput;
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
||||||
pub use provider::ExecutorPluginProvider;
|
pub use provider::ExecutorPluginProvider;
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ use codex_config::ConfigLayerStack;
|
|||||||
use codex_config::clear_user_plugin;
|
use codex_config::clear_user_plugin;
|
||||||
use codex_config::set_user_plugin_enabled;
|
use codex_config::set_user_plugin_enabled;
|
||||||
use codex_config::types::PluginConfig;
|
use codex_config::types::PluginConfig;
|
||||||
|
use codex_config::types::ToolSuggestDisabledTool;
|
||||||
|
use codex_config::types::ToolSuggestDiscoverableType;
|
||||||
use codex_core_skills::SkillMetadata;
|
use codex_core_skills::SkillMetadata;
|
||||||
use codex_core_skills::config_rules::SkillConfigRules;
|
use codex_core_skills::config_rules::SkillConfigRules;
|
||||||
use codex_core_skills::config_rules::skill_config_rules_from_stack;
|
use codex_core_skills::config_rules::skill_config_rules_from_stack;
|
||||||
@@ -71,6 +73,9 @@ use codex_plugin::app_connector_ids_from_declarations;
|
|||||||
use codex_plugin::prompt_safe_plugin_description;
|
use codex_plugin::prompt_safe_plugin_description;
|
||||||
use codex_protocol::protocol::HookEventName;
|
use codex_protocol::protocol::HookEventName;
|
||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
|
use codex_tools::DiscoverablePluginInfo;
|
||||||
|
use codex_tools::DiscoverableTool;
|
||||||
|
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -114,6 +119,15 @@ impl PluginsConfigInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inputs used to select endpoint-backed plugin install candidates.
|
||||||
|
pub struct RecommendedPluginCandidatesInput<'a> {
|
||||||
|
pub plugins_config: &'a PluginsConfigInput,
|
||||||
|
pub loaded_plugins: &'a PluginLoadOutcome,
|
||||||
|
pub auth: Option<&'a CodexAuth>,
|
||||||
|
pub disabled_tools: &'a [ToolSuggestDisabledTool],
|
||||||
|
pub app_server_client_name: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
struct FeaturedPluginIdsCacheKey {
|
struct FeaturedPluginIdsCacheKey {
|
||||||
chatgpt_base_url: String,
|
chatgpt_base_url: String,
|
||||||
@@ -997,6 +1011,59 @@ impl PluginsManager {
|
|||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns endpoint recommendations eligible for installation in the current client.
|
||||||
|
/// `None` selects the legacy discovery workflow.
|
||||||
|
pub async fn recommended_plugin_candidates_for_config(
|
||||||
|
&self,
|
||||||
|
input: RecommendedPluginCandidatesInput<'_>,
|
||||||
|
) -> Option<Vec<DiscoverableTool>> {
|
||||||
|
let RecommendedPluginsMode::Endpoint { plugins } = self
|
||||||
|
.recommended_plugins_mode_for_config(input.plugins_config, input.auth)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if plugins.is_empty() {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed_plugin_ids = input
|
||||||
|
.loaded_plugins
|
||||||
|
.plugins()
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| plugin.config_name.as_str())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let disabled_plugin_ids = input
|
||||||
|
.disabled_tools
|
||||||
|
.iter()
|
||||||
|
.filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin)
|
||||||
|
.map(|tool| tool.id.as_str())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
let candidates = plugins
|
||||||
|
.into_iter()
|
||||||
|
.filter(|plugin| {
|
||||||
|
!installed_plugin_ids.contains(plugin.config_id.as_str())
|
||||||
|
&& !disabled_plugin_ids.contains(plugin.config_id.as_str())
|
||||||
|
})
|
||||||
|
.map(|plugin| {
|
||||||
|
DiscoverableTool::from(DiscoverablePluginInfo {
|
||||||
|
id: plugin.config_id,
|
||||||
|
remote_plugin_id: Some(plugin.remote_plugin_id),
|
||||||
|
name: plugin.display_name,
|
||||||
|
description: None,
|
||||||
|
has_skills: false,
|
||||||
|
mcp_server_names: Vec::new(),
|
||||||
|
app_connector_ids: plugin.app_connector_ids,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(filter_request_plugin_install_discoverable_tools_for_client(
|
||||||
|
candidates,
|
||||||
|
input.app_server_client_name,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn cached_recommended_plugins_mode(
|
fn cached_recommended_plugins_mode(
|
||||||
&self,
|
&self,
|
||||||
cache_key: &RecommendedPluginsCacheKey,
|
cache_key: &RecommendedPluginsCacheKey,
|
||||||
|
|||||||
@@ -3844,6 +3844,79 @@ remote_plugin = true
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_file(
|
||||||
|
&tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"[features]
|
||||||
|
plugins = true
|
||||||
|
remote_plugin = true
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/ps/plugins/suggested"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"id": "plugin_linear",
|
||||||
|
"name": "linear",
|
||||||
|
"release": {"display_name": "Linear"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plugin_github",
|
||||||
|
"name": "github",
|
||||||
|
"release": {"display_name": "GitHub"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plugin_slack",
|
||||||
|
"name": "slack",
|
||||||
|
"release": {"display_name": "Slack"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||||
|
config.chatgpt_base_url = server.uri();
|
||||||
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||||
|
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
|
||||||
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||||
|
let disabled_tools = [ToolSuggestDisabledTool::plugin(
|
||||||
|
"github@openai-curated-remote",
|
||||||
|
)];
|
||||||
|
let loaded_plugins = manager.plugins_for_config(&config).await;
|
||||||
|
|
||||||
|
let candidates = manager
|
||||||
|
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
|
||||||
|
plugins_config: &config,
|
||||||
|
loaded_plugins: &loaded_plugins,
|
||||||
|
auth: Some(&auth),
|
||||||
|
disabled_tools: &disabled_tools,
|
||||||
|
app_server_client_name: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
candidates,
|
||||||
|
Some(vec![DiscoverableTool::from(DiscoverablePluginInfo {
|
||||||
|
id: "slack@openai-curated-remote".to_string(),
|
||||||
|
remote_plugin_id: Some("plugin_slack".to_string()),
|
||||||
|
name: "Slack".to_string(),
|
||||||
|
description: None,
|
||||||
|
has_skills: false,
|
||||||
|
mcp_server_names: Vec::new(),
|
||||||
|
app_connector_ids: Vec::new(),
|
||||||
|
})])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recommended_plugins_mode_caches_explicit_false() {
|
async fn recommended_plugins_mode_caches_explicit_false() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use super::InternalModelContextFragment;
|
|||||||
use super::LegacyApplyPatchExecCommandWarning;
|
use super::LegacyApplyPatchExecCommandWarning;
|
||||||
use super::LegacyModelMismatchWarning;
|
use super::LegacyModelMismatchWarning;
|
||||||
use super::LegacyUnifiedExecProcessLimitWarning;
|
use super::LegacyUnifiedExecProcessLimitWarning;
|
||||||
|
use super::RecommendedPluginsInstructions;
|
||||||
use super::SkillInstructions;
|
use super::SkillInstructions;
|
||||||
use super::SubagentNotification;
|
use super::SubagentNotification;
|
||||||
use super::TurnAborted;
|
use super::TurnAborted;
|
||||||
@@ -33,6 +34,8 @@ static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy<SubagentNot
|
|||||||
static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<
|
static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<
|
||||||
InternalModelContextFragment,
|
InternalModelContextFragment,
|
||||||
> = FragmentRegistrationProxy::new();
|
> = FragmentRegistrationProxy::new();
|
||||||
|
static RECOMMENDED_PLUGINS_REGISTRATION: FragmentRegistrationProxy<RecommendedPluginsInstructions> =
|
||||||
|
FragmentRegistrationProxy::new();
|
||||||
static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy<
|
static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy<
|
||||||
LegacyUnifiedExecProcessLimitWarning,
|
LegacyUnifiedExecProcessLimitWarning,
|
||||||
> = FragmentRegistrationProxy::new();
|
> = FragmentRegistrationProxy::new();
|
||||||
@@ -52,6 +55,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[
|
|||||||
&TURN_ABORTED_REGISTRATION,
|
&TURN_ABORTED_REGISTRATION,
|
||||||
&SUBAGENT_NOTIFICATION_REGISTRATION,
|
&SUBAGENT_NOTIFICATION_REGISTRATION,
|
||||||
&INTERNAL_MODEL_CONTEXT_REGISTRATION,
|
&INTERNAL_MODEL_CONTEXT_REGISTRATION,
|
||||||
|
&RECOMMENDED_PLUGINS_REGISTRATION,
|
||||||
&LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION,
|
&LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION,
|
||||||
&LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION,
|
&LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION,
|
||||||
&LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION,
|
&LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION,
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ fn detects_internal_model_context_fragment() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_recommended_plugins_fragment() {
|
||||||
|
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||||
|
text: "<recommended_plugins>\n- Google Drive (google-drive@openai-curated-remote)\n</recommended_plugins>"
|
||||||
|
.to_string(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_legacy_goal_context_fragment() {
|
fn detects_legacy_goal_context_fragment() {
|
||||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ mod plugin_instructions;
|
|||||||
mod realtime_end_instructions;
|
mod realtime_end_instructions;
|
||||||
mod realtime_start_instructions;
|
mod realtime_start_instructions;
|
||||||
mod realtime_start_with_instructions;
|
mod realtime_start_with_instructions;
|
||||||
|
mod recommended_plugins_instructions;
|
||||||
mod subagent_notification;
|
mod subagent_notification;
|
||||||
mod token_budget_context;
|
mod token_budget_context;
|
||||||
mod turn_aborted;
|
mod turn_aborted;
|
||||||
@@ -62,6 +63,7 @@ pub(crate) use plugin_instructions::PluginInstructions;
|
|||||||
pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
|
pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
|
||||||
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
|
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
|
||||||
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
|
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
|
||||||
|
pub(crate) use recommended_plugins_instructions::RecommendedPluginsInstructions;
|
||||||
pub(crate) use subagent_notification::SubagentNotification;
|
pub(crate) use subagent_notification::SubagentNotification;
|
||||||
pub(crate) use token_budget_context::TokenBudgetContext;
|
pub(crate) use token_budget_context::TokenBudgetContext;
|
||||||
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
|
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use super::ContextualUserFragment;
|
||||||
|
use codex_tools::DiscoverableTool;
|
||||||
|
|
||||||
|
const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. All entries have `tool_type: plugin`; pass `plugin` as `tool_type` and the parenthesized ID as `tool_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive.";
|
||||||
|
const MAX_RECOMMENDED_PLUGINS: usize = 50;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub(crate) struct RecommendedPluginsInstructions {
|
||||||
|
plugins: Vec<DiscoverableTool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecommendedPluginsInstructions {
|
||||||
|
pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option<Self> {
|
||||||
|
if plugins.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
plugins: plugins
|
||||||
|
.iter()
|
||||||
|
.take(MAX_RECOMMENDED_PLUGINS)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextualUserFragment for RecommendedPluginsInstructions {
|
||||||
|
fn role(&self) -> &'static str {
|
||||||
|
"user"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markers(&self) -> (&'static str, &'static str) {
|
||||||
|
Self::type_markers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_markers() -> (&'static str, &'static str) {
|
||||||
|
("<recommended_plugins>", "</recommended_plugins>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(&self) -> String {
|
||||||
|
let plugins = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| format!("- {} ({})", plugin.name(), plugin.id()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("\n{RECOMMENDED_PLUGINS_INTRO}\n\n{plugins}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ use crate::context::ContextualUserFragment;
|
|||||||
use crate::context::NetworkRuleSaved;
|
use crate::context::NetworkRuleSaved;
|
||||||
use crate::context::PermissionsInstructions;
|
use crate::context::PermissionsInstructions;
|
||||||
use crate::context::PersonalitySpecInstructions;
|
use crate::context::PersonalitySpecInstructions;
|
||||||
|
use crate::context::RecommendedPluginsInstructions;
|
||||||
use crate::default_skill_metadata_budget;
|
use crate::default_skill_metadata_budget;
|
||||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||||
use crate::exec_policy::ExecPolicyManager;
|
use crate::exec_policy::ExecPolicyManager;
|
||||||
@@ -325,6 +326,7 @@ use crate::turn_timing::record_turn_ttfm_metric;
|
|||||||
use crate::unified_exec::UnifiedExecProcessManager;
|
use crate::unified_exec::UnifiedExecProcessManager;
|
||||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||||
use codex_core_plugins::PluginsManager;
|
use codex_core_plugins::PluginsManager;
|
||||||
|
use codex_core_plugins::RecommendedPluginCandidatesInput;
|
||||||
use codex_git_utils::get_git_repo_root;
|
use codex_git_utils::get_git_repo_root;
|
||||||
use codex_mcp::McpConfig;
|
use codex_mcp::McpConfig;
|
||||||
use codex_mcp::compute_auth_statuses;
|
use codex_mcp::compute_auth_statuses;
|
||||||
@@ -2975,6 +2977,29 @@ impl Session {
|
|||||||
.plugins_manager
|
.plugins_manager
|
||||||
.plugins_for_config(&turn_context.config.plugins_config_input())
|
.plugins_for_config(&turn_context.config.plugins_config_input())
|
||||||
.await;
|
.await;
|
||||||
|
let recommended_plugin_candidates =
|
||||||
|
if crate::tools::spec_plan::tool_suggest_enabled(turn_context) {
|
||||||
|
let auth = self.services.auth_manager.auth().await;
|
||||||
|
let plugins_config = turn_context.config.plugins_config_input();
|
||||||
|
self.services
|
||||||
|
.plugins_manager
|
||||||
|
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
|
||||||
|
plugins_config: &plugins_config,
|
||||||
|
loaded_plugins: &loaded_plugins,
|
||||||
|
auth: auth.as_ref(),
|
||||||
|
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
|
||||||
|
app_server_client_name: turn_context.app_server_client_name.as_deref(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(recommended_plugins) = recommended_plugin_candidates
|
||||||
|
.as_deref()
|
||||||
|
.and_then(RecommendedPluginsInstructions::from_plugins)
|
||||||
|
{
|
||||||
|
contextual_user_sections.push(recommended_plugins.render());
|
||||||
|
}
|
||||||
if let Some(plugin_instructions) =
|
if let Some(plugin_instructions) =
|
||||||
AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries())
|
AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ use codex_analytics::InvocationType;
|
|||||||
use codex_analytics::TurnResolvedConfigFact;
|
use codex_analytics::TurnResolvedConfigFact;
|
||||||
use codex_analytics::build_track_events_context;
|
use codex_analytics::build_track_events_context;
|
||||||
use codex_async_utils::OrCancelExt;
|
use codex_async_utils::OrCancelExt;
|
||||||
|
use codex_core_plugins::RecommendedPluginCandidatesInput;
|
||||||
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
||||||
use codex_extension_api::TurnInputContext;
|
use codex_extension_api::TurnInputContext;
|
||||||
use codex_extension_api::TurnInputEnvironment;
|
use codex_extension_api::TurnInputEnvironment;
|
||||||
@@ -1186,49 +1187,78 @@ pub(crate) async fn built_tools(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let auth = sess.services.auth_manager.auth().await;
|
let tool_suggest_is_enabled = tool_suggest_enabled(turn_context);
|
||||||
let loaded_plugin_app_connector_ids = loaded_plugins
|
let auth = if tool_suggest_is_enabled {
|
||||||
.effective_apps()
|
sess.services.auth_manager.auth().await
|
||||||
.into_iter()
|
} else {
|
||||||
.map(|connector_id| connector_id.0)
|
None
|
||||||
.collect::<Vec<_>>();
|
};
|
||||||
let tool_suggest_candidates = async {
|
let endpoint_recommended_plugin_candidates = if tool_suggest_is_enabled {
|
||||||
if apps_enabled && tool_suggest_enabled(turn_context) {
|
let plugins_config = turn_context.config.plugins_config_input();
|
||||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
sess.services
|
||||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
.plugins_manager
|
||||||
&turn_context.config,
|
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
|
||||||
sess.services.plugins_manager.as_ref(),
|
plugins_config: &plugins_config,
|
||||||
auth.as_ref(),
|
loaded_plugins: &loaded_plugins,
|
||||||
accessible_connectors.as_slice(),
|
auth: auth.as_ref(),
|
||||||
&loaded_plugin_app_connector_ids,
|
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
|
||||||
)
|
app_server_client_name: turn_context.app_server_client_name.as_deref(),
|
||||||
.await
|
})
|
||||||
.map(|discoverable_tools| {
|
.await
|
||||||
filter_request_plugin_install_discoverable_tools_for_client(
|
} else {
|
||||||
discoverable_tools,
|
None
|
||||||
turn_context.app_server_client_name.as_deref(),
|
};
|
||||||
)
|
let tool_suggest_candidates =
|
||||||
}) {
|
if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates {
|
||||||
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
|
Some(ToolSuggestCandidates {
|
||||||
Ok(discoverable_tools) => Some(ToolSuggestCandidates {
|
tools: recommended_plugin_candidates,
|
||||||
tools: discoverable_tools,
|
presentation: ToolSuggestPresentation::RecommendationContext,
|
||||||
presentation: ToolSuggestPresentation::ListTool,
|
})
|
||||||
}),
|
} else {
|
||||||
Err(err) => {
|
let loaded_plugin_app_connector_ids = loaded_plugins
|
||||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
.effective_apps()
|
||||||
|
.into_iter()
|
||||||
|
.map(|connector_id| connector_id.0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
async {
|
||||||
|
if apps_enabled && tool_suggest_is_enabled {
|
||||||
|
if let Some(accessible_connectors) =
|
||||||
|
accessible_connectors_with_enabled_state.as_ref()
|
||||||
|
{
|
||||||
|
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||||
|
&turn_context.config,
|
||||||
|
sess.services.plugins_manager.as_ref(),
|
||||||
|
auth.as_ref(),
|
||||||
|
accessible_connectors.as_slice(),
|
||||||
|
&loaded_plugin_app_connector_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|discoverable_tools| {
|
||||||
|
filter_request_plugin_install_discoverable_tools_for_client(
|
||||||
|
discoverable_tools,
|
||||||
|
turn_context.app_server_client_name.as_deref(),
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
|
||||||
|
Ok(discoverable_tools) => Some(ToolSuggestCandidates {
|
||||||
|
tools: discoverable_tools,
|
||||||
|
presentation: ToolSuggestPresentation::ListTool,
|
||||||
|
}),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
} else {
|
.instrument(trace_span!("built_tools.load_discoverable_tools"))
|
||||||
None
|
.await
|
||||||
}
|
};
|
||||||
}
|
|
||||||
.instrument(trace_span!("built_tools.load_discoverable_tools"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mcp_tool_exposure = build_mcp_tool_exposure(
|
let mcp_tool_exposure = build_mcp_tool_exposure(
|
||||||
&all_mcp_tools,
|
&all_mcp_tools,
|
||||||
connectors.as_deref(),
|
connectors.as_deref(),
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ impl RequestPluginInstallHandler {
|
|||||||
ToolSuggestPresentation::ListTool => format!(
|
ToolSuggestPresentation::ListTool => format!(
|
||||||
"the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}"
|
"the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}"
|
||||||
),
|
),
|
||||||
ToolSuggestPresentation::DeveloperContext => {
|
ToolSuggestPresentation::RecommendationContext => {
|
||||||
"the developer recommendations".to_string()
|
"the <recommended_plugins> list".to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
FunctionCallError::RespondToModel(format!(
|
FunctionCallError::RespondToModel(format!(
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ pub(crate) fn create_request_plugin_install_tool(
|
|||||||
ToolSuggestPresentation::ListTool => format!(
|
ToolSuggestPresentation::ListTool => format!(
|
||||||
"# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
|
"# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
|
||||||
),
|
),
|
||||||
ToolSuggestPresentation::DeveloperContext =>
|
ToolSuggestPresentation::RecommendationContext =>
|
||||||
"# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(),
|
"# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
ToolSpec::Function(ResponsesApiTool {
|
ToolSpec::Function(ResponsesApiTool {
|
||||||
@@ -126,15 +126,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn developer_recommendations_change_only_the_description() {
|
fn recommendation_context_changes_only_the_description() {
|
||||||
let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool);
|
let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool);
|
||||||
let recommendations =
|
let recommendations =
|
||||||
create_request_plugin_install_tool(ToolSuggestPresentation::DeveloperContext);
|
create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext);
|
||||||
|
|
||||||
let ToolSpec::Function(expected_function) = &mut expected else {
|
let ToolSpec::Function(expected_function) = &mut expected else {
|
||||||
panic!("expected function tool specs");
|
panic!("expected function tool specs");
|
||||||
};
|
};
|
||||||
expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string();
|
expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string();
|
||||||
|
|
||||||
assert_eq!(recommendations, expected);
|
assert_eq!(recommendations, expected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ pub(crate) struct ToolRouterParams<'a> {
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum ToolSuggestPresentation {
|
pub(crate) enum ToolSuggestPresentation {
|
||||||
ListTool,
|
ListTool,
|
||||||
#[allow(dead_code)]
|
RecommendationContext,
|
||||||
DeveloperContext,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ async fn request_plugin_install_requires_all_discovery_features() {
|
|||||||
None,
|
None,
|
||||||
Some(ToolSuggestCandidates {
|
Some(ToolSuggestCandidates {
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
presentation: ToolSuggestPresentation::DeveloperContext,
|
presentation: ToolSuggestPresentation::RecommendationContext,
|
||||||
}),
|
}),
|
||||||
] {
|
] {
|
||||||
let plan = probe_with(
|
let plan = probe_with(
|
||||||
@@ -959,7 +959,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint()
|
|||||||
},
|
},
|
||||||
ToolPlanInputs {
|
ToolPlanInputs {
|
||||||
tool_suggest_candidates: Some(plugin_candidates(
|
tool_suggest_candidates: Some(plugin_candidates(
|
||||||
ToolSuggestPresentation::DeveloperContext,
|
ToolSuggestPresentation::RecommendationContext,
|
||||||
)),
|
)),
|
||||||
..ToolPlanInputs::default()
|
..ToolPlanInputs::default()
|
||||||
},
|
},
|
||||||
@@ -973,7 +973,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint()
|
|||||||
else {
|
else {
|
||||||
panic!("expected request_plugin_install function spec");
|
panic!("expected request_plugin_install function spec");
|
||||||
};
|
};
|
||||||
assert!(request_description.contains("developer `<recommended_plugins>` list"));
|
assert!(request_description.contains("the `<recommended_plugins>` list"));
|
||||||
assert!(!request_description.contains("list_available_plugins_to_install"));
|
assert!(!request_description.contains("list_available_plugins_to_install"));
|
||||||
assert!(!request_description.contains("github"));
|
assert!(!request_description.contains("github"));
|
||||||
plan.assert_visible_lacks(&["list_available_plugins_to_install"]);
|
plan.assert_visible_lacks(&["list_available_plugins_to_install"]);
|
||||||
|
|||||||
@@ -2,24 +2,46 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use codex_config::types::ToolSuggestDisabledTool;
|
||||||
use codex_config::types::ToolSuggestDiscoverable;
|
use codex_config::types::ToolSuggestDiscoverable;
|
||||||
use codex_config::types::ToolSuggestDiscoverableType;
|
use codex_config::types::ToolSuggestDiscoverableType;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_login::CodexAuth;
|
use codex_login::CodexAuth;
|
||||||
use codex_models_manager::bundled_models_response;
|
use codex_models_manager::bundled_models_response;
|
||||||
|
use codex_protocol::approvals::ElicitationAction;
|
||||||
|
use codex_protocol::approvals::ElicitationRequest;
|
||||||
|
use codex_protocol::config_types::CollaborationMode;
|
||||||
|
use codex_protocol::config_types::ModeKind;
|
||||||
|
use codex_protocol::config_types::Settings;
|
||||||
use codex_protocol::models::PermissionProfile;
|
use codex_protocol::models::PermissionProfile;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
|
use codex_protocol::protocol::EventMsg;
|
||||||
|
use codex_protocol::protocol::Op;
|
||||||
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||||
|
use codex_protocol::user_input::UserInput;
|
||||||
use core_test_support::apps_test_server::AppsTestServer;
|
use core_test_support::apps_test_server::AppsTestServer;
|
||||||
use core_test_support::responses::ev_assistant_message;
|
use core_test_support::responses::ev_assistant_message;
|
||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
|
use core_test_support::responses::ev_function_call;
|
||||||
use core_test_support::responses::ev_response_created;
|
use core_test_support::responses::ev_response_created;
|
||||||
use core_test_support::responses::mount_sse_once;
|
use core_test_support::responses::mount_sse_once;
|
||||||
|
use core_test_support::responses::mount_sse_sequence;
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::responses::start_mock_server;
|
use core_test_support::responses::start_mock_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
|
use core_test_support::test_codex::TestCodex;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
|
use core_test_support::test_codex::turn_permission_fields;
|
||||||
|
use core_test_support::wait_for_event;
|
||||||
|
use core_test_support::wait_for_event_match;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use serde_json::json;
|
||||||
|
use wiremock::Mock;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use wiremock::matchers::method;
|
||||||
|
use wiremock::matchers::path;
|
||||||
|
use wiremock::matchers::query_param;
|
||||||
|
|
||||||
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||||
const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install";
|
const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install";
|
||||||
@@ -43,36 +65,20 @@ fn tool_names(body: &Value) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn function_tool_description(body: &Value, name: &str) -> Option<String> {
|
|
||||||
body.get("tools")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|tools| {
|
|
||||||
tools.iter().find_map(|tool| {
|
|
||||||
if tool.get("name").and_then(Value::as_str) == Some(name) {
|
|
||||||
tool.get("description")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) {
|
fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) {
|
||||||
config
|
for feature in [
|
||||||
.features
|
Feature::Apps,
|
||||||
.enable(Feature::Apps)
|
Feature::Plugins,
|
||||||
.expect("test config should allow feature update");
|
Feature::RemotePlugin,
|
||||||
config
|
Feature::ToolSuggest,
|
||||||
.features
|
] {
|
||||||
.enable(Feature::Plugins)
|
config
|
||||||
.expect("test config should allow feature update");
|
.features
|
||||||
config
|
.enable(feature)
|
||||||
.features
|
.expect("test config should allow feature update");
|
||||||
.enable(Feature::ToolSuggest)
|
}
|
||||||
.expect("test config should allow feature update");
|
let mut model_catalog = bundled_models_response()
|
||||||
let mut model_catalog = bundled_models_response().expect("bundled models.json should parse");
|
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
|
||||||
let model = model_catalog
|
let model = model_catalog
|
||||||
.models
|
.models
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -88,13 +94,326 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str)
|
|||||||
config.model_catalog = Some(model_catalog);
|
config.model_catalog = Some(model_catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mount_recommendations(server: &wiremock::MockServer, response: ResponseTemplate) {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/ps/plugins/suggested"))
|
||||||
|
.and(query_param("scope", "GLOBAL"))
|
||||||
|
.respond_with(response)
|
||||||
|
.mount(server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_legacy_tools(body: &Value) {
|
||||||
|
let tools = tool_names(body);
|
||||||
|
assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
|
||||||
|
assert!(
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME),
|
||||||
|
"legacy mode should expose {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
|
||||||
|
"legacy mode should expose {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_test(
|
||||||
|
server: &wiremock::MockServer,
|
||||||
|
apps_server: &AppsTestServer,
|
||||||
|
) -> Result<TestCodex> {
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||||
|
.with_config({
|
||||||
|
let apps_base_url = apps_server.chatgpt_base_url.clone();
|
||||||
|
move |config| configure_apps_without_search_tool(config, apps_base_url.as_str())
|
||||||
|
});
|
||||||
|
builder.build(server).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts()
|
async fn explicit_false_preserves_legacy_workflow() -> Result<()> {
|
||||||
-> Result<()> {
|
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
let apps_server = AppsTestServer::mount(&server).await?;
|
let apps_server = AppsTestServer::mount(&server).await?;
|
||||||
|
mount_recommendations(
|
||||||
|
&server,
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({"enabled": false, "plugins": []})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let call_id = "list-installable-tools";
|
||||||
|
let mock = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
ev_function_call(call_id, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME, "{}"),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-2"),
|
||||||
|
ev_assistant_message("msg-1", "done"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let test = build_test(&server, &apps_server).await?;
|
||||||
|
test.submit_turn_with_approval_and_permission_profile(
|
||||||
|
"list tools",
|
||||||
|
AskForApproval::Never,
|
||||||
|
PermissionProfile::Disabled,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let requests = mock.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let request = &requests[0];
|
||||||
|
assert!(
|
||||||
|
!request
|
||||||
|
.message_input_texts("user")
|
||||||
|
.join("\n")
|
||||||
|
.contains("<recommended_plugins>")
|
||||||
|
);
|
||||||
|
assert_legacy_tools(&request.body_json());
|
||||||
|
let output = requests[1]
|
||||||
|
.function_call_output_text(call_id)
|
||||||
|
.expect("list tool output");
|
||||||
|
let output: Value = serde_json::from_str(&output)?;
|
||||||
|
assert!(output["tools"].as_array().is_some_and(|tools| {
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["id"] == DISCOVERABLE_GMAIL_ID && tool["tool_type"] == "connector")
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let apps_server = AppsTestServer::mount(&server).await?;
|
||||||
|
mount_recommendations(
|
||||||
|
&server,
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"id": "plugin_google_calendar",
|
||||||
|
"name": "google-calendar",
|
||||||
|
"status": "ENABLED",
|
||||||
|
"installation_policy": "AVAILABLE",
|
||||||
|
"release": {"display_name": "Google Calendar"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plugin_github",
|
||||||
|
"name": "github",
|
||||||
|
"status": "ENABLED",
|
||||||
|
"installation_policy": "AVAILABLE",
|
||||||
|
"release": {"display_name": "GitHub"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let call_id = "invented-plugin";
|
||||||
|
let mock = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
ev_function_call(
|
||||||
|
call_id,
|
||||||
|
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
|
||||||
|
&serde_json::to_string(&json!({
|
||||||
|
"tool_type": "plugin",
|
||||||
|
"action_type": "install",
|
||||||
|
"tool_id": "invented@openai-curated-remote",
|
||||||
|
"suggest_reason": "Try this"
|
||||||
|
}))?,
|
||||||
|
),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-2"),
|
||||||
|
ev_assistant_message("msg-1", "done"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let test = build_test(&server, &apps_server).await?;
|
||||||
|
|
||||||
|
test.submit_turn("suggest a plugin").await?;
|
||||||
|
|
||||||
|
let requests = mock.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let contextual_user_message = requests[0].message_input_texts("user").join("\n");
|
||||||
|
assert!(contextual_user_message.contains("<recommended_plugins>"));
|
||||||
|
assert!(contextual_user_message.contains("github@openai-curated-remote"));
|
||||||
|
assert!(contextual_user_message.contains("google-calendar@openai-curated-remote"));
|
||||||
|
let body = requests[0].body_json();
|
||||||
|
let tools = tool_names(&body);
|
||||||
|
assert!(
|
||||||
|
!tools
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME)
|
||||||
|
);
|
||||||
|
let output = requests[1]
|
||||||
|
.function_call_output_text(call_id)
|
||||||
|
.expect("request tool output");
|
||||||
|
assert!(output.contains("<recommended_plugins> list"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metadata() -> Result<()>
|
||||||
|
{
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
const REMOTE_PLUGIN_ID: &str = "plugin_connector_github";
|
||||||
|
const APP_CONNECTOR_ID: &str = "connector_github";
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let apps_server = AppsTestServer::mount(&server).await?;
|
||||||
|
mount_recommendations(
|
||||||
|
&server,
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": [{
|
||||||
|
"id": REMOTE_PLUGIN_ID,
|
||||||
|
"name": "github",
|
||||||
|
"status": "ENABLED",
|
||||||
|
"installation_policy": "AVAILABLE",
|
||||||
|
"release": {
|
||||||
|
"display_name": "GitHub",
|
||||||
|
"app_ids": [APP_CONNECTOR_ID]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let call_id = "install-github";
|
||||||
|
let mock = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
ev_function_call(
|
||||||
|
call_id,
|
||||||
|
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
|
||||||
|
&serde_json::to_string(&json!({
|
||||||
|
"tool_type": "plugin",
|
||||||
|
"action_type": "install",
|
||||||
|
"tool_id": "github@openai-curated-remote",
|
||||||
|
"suggest_reason": "Use GitHub for this request"
|
||||||
|
}))?,
|
||||||
|
),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-2"),
|
||||||
|
ev_assistant_message("msg-1", "done"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let test = build_test(&server, &apps_server).await?;
|
||||||
|
let (sandbox_policy, permission_profile) =
|
||||||
|
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||||
|
|
||||||
|
test.codex
|
||||||
|
.submit(Op::UserInput {
|
||||||
|
items: vec![UserInput::Text {
|
||||||
|
text: "use GitHub".into(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}],
|
||||||
|
final_output_json_schema: None,
|
||||||
|
responsesapi_client_metadata: None,
|
||||||
|
additional_context: Default::default(),
|
||||||
|
thread_settings: ThreadSettingsOverrides {
|
||||||
|
approval_policy: Some(AskForApproval::Never),
|
||||||
|
sandbox_policy: Some(sandbox_policy),
|
||||||
|
permission_profile,
|
||||||
|
collaboration_mode: Some(CollaborationMode {
|
||||||
|
mode: ModeKind::Default,
|
||||||
|
settings: Settings {
|
||||||
|
model: test.session_configured.model.clone(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
developer_instructions: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let elicitation = wait_for_event_match(&test.codex, |event| match event {
|
||||||
|
EventMsg::ElicitationRequest(request) => Some(request.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let ElicitationRequest::Form {
|
||||||
|
meta: Some(meta), ..
|
||||||
|
} = &elicitation.request
|
||||||
|
else {
|
||||||
|
panic!("expected form elicitation metadata");
|
||||||
|
};
|
||||||
|
assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
||||||
|
assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID]));
|
||||||
|
|
||||||
|
test.codex
|
||||||
|
.submit(Op::ResolveElicitation {
|
||||||
|
server_name: elicitation.server_name,
|
||||||
|
request_id: elicitation.id,
|
||||||
|
decision: ElicitationAction::Decline,
|
||||||
|
content: None,
|
||||||
|
meta: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let requests = mock.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
for request in requests {
|
||||||
|
let body = request.body_json().to_string();
|
||||||
|
assert!(!body.contains(REMOTE_PLUGIN_ID));
|
||||||
|
assert!(!body.contains(APP_CONNECTOR_ID));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let apps_server = AppsTestServer::mount(&server).await?;
|
||||||
|
mount_recommendations(
|
||||||
|
&server,
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": [{
|
||||||
|
"id": "plugin_google_calendar",
|
||||||
|
"name": "google-calendar",
|
||||||
|
"release": {"display_name": "Google Calendar"}
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let mock = mount_sse_once(
|
let mock = mount_sse_once(
|
||||||
&server,
|
&server,
|
||||||
sse(vec![
|
sse(vec![
|
||||||
@@ -104,61 +423,38 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let mut builder = test_codex()
|
let mut builder = test_codex()
|
||||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||||
.with_config(move |config| {
|
.with_config({
|
||||||
configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str())
|
let apps_base_url = apps_server.chatgpt_base_url.clone();
|
||||||
|
move |config| {
|
||||||
|
configure_apps_without_search_tool(config, apps_base_url.as_str());
|
||||||
|
config.tool_suggest.disabled_tools = vec![ToolSuggestDisabledTool::plugin(
|
||||||
|
"google-calendar@openai-curated-remote",
|
||||||
|
)];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let test = builder.build(&server).await?;
|
let test = builder.build(&server).await?;
|
||||||
|
|
||||||
test.submit_turn_with_approval_and_permission_profile(
|
test.submit_turn("list tools").await?;
|
||||||
"list tools",
|
|
||||||
AskForApproval::Never,
|
|
||||||
PermissionProfile::Disabled,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let body = mock.single_request().body_json();
|
let request = mock.single_request();
|
||||||
let tools = tool_names(&body);
|
|
||||||
assert!(
|
assert!(
|
||||||
!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
|
!request
|
||||||
"tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}"
|
.message_input_texts("user")
|
||||||
|
.join("\n")
|
||||||
|
.contains("<recommended_plugins>")
|
||||||
);
|
);
|
||||||
|
let tools = tool_names(&request.body_json());
|
||||||
assert!(
|
assert!(
|
||||||
tools
|
!tools
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME),
|
.any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
|
||||||
"tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
tools
|
!tools
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
|
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME)
|
||||||
"tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let list_description =
|
|
||||||
function_tool_description(&body, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
|
|
||||||
.expect("description");
|
|
||||||
assert!(list_description.contains(
|
|
||||||
"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!(list_description.contains(
|
|
||||||
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
|
|
||||||
));
|
|
||||||
assert!(list_description.contains(
|
|
||||||
"When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed."
|
|
||||||
));
|
|
||||||
|
|
||||||
let description =
|
|
||||||
function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description");
|
|
||||||
assert!(description.contains(
|
|
||||||
"Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request."
|
|
||||||
));
|
|
||||||
assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools."));
|
|
||||||
assert!(!description.contains(DISCOVERABLE_GMAIL_ID));
|
|
||||||
assert!(!description.contains("tool_search fails to find a good match"));
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user