mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Support disabling tool suggest for specific tools. (#20072)
## Summary - Add `disable_tool_suggest` to app and plugin config, schema, and TypeScript output - Exclude disabled connectors and plugins from tool suggestion discovery - Persist "never show again" tool-suggestion choices back into `config.toml` - Update config docs and add coverage for connector and plugin suppression ## Testing - Added and updated unit tests for config persistence and tool-suggest filtering - Not run (not requested)
This commit is contained in:
committed by
GitHub
Unverified
parent
1211a90a35
commit
ebdf3a878c
@@ -186,11 +186,45 @@ pub struct ToolSuggestDiscoverable {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolSuggestDisabledTool {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: ToolSuggestDiscoverableType,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl ToolSuggestDisabledTool {
|
||||
pub fn plugin(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ToolSuggestDiscoverableType::Plugin,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connector(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ToolSuggestDiscoverableType::Connector,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized(&self) -> Option<Self> {
|
||||
let id = self.id.trim();
|
||||
(!id.is_empty()).then(|| Self {
|
||||
kind: self.kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolSuggestConfig {
|
||||
#[serde(default)]
|
||||
pub discoverables: Vec<ToolSuggestDiscoverable>,
|
||||
#[serde(default)]
|
||||
pub disabled_tools: Vec<ToolSuggestDisabledTool>,
|
||||
}
|
||||
|
||||
/// Memories settings loaded from config.toml.
|
||||
|
||||
@@ -2169,6 +2169,13 @@
|
||||
"ToolSuggestConfig": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"disabled_tools": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ToolSuggestDisabledTool"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"discoverables": {
|
||||
"default": [],
|
||||
"items": {
|
||||
@@ -2179,6 +2186,22 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ToolSuggestDisabledTool": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/ToolSuggestDiscoverableType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ToolSuggestDiscoverable": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_config::types::NotificationMethod;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_config::types::SkillsConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_config::types::Tui;
|
||||
use codex_config::types::TuiKeymap;
|
||||
@@ -8144,6 +8145,7 @@ discoverables = [
|
||||
id: " ".to_string(),
|
||||
},
|
||||
],
|
||||
disabled_tools: Vec::new(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -8168,11 +8170,118 @@ discoverables = [
|
||||
id: "plugin_alpha@openai-curated".to_string(),
|
||||
},
|
||||
],
|
||||
disabled_tools: Vec::new(),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_disabled_tools_load_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
{ type = "connector", id = " connector_calendar " },
|
||||
{ type = "connector", id = "connector_calendar" },
|
||||
{ type = "connector", id = " " },
|
||||
{ type = "plugin", id = "slack@openai-curated" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
|
||||
assert_eq!(
|
||||
cfg.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![
|
||||
ToolSuggestDisabledTool::connector(" connector_calendar "),
|
||||
ToolSuggestDisabledTool::connector("connector_calendar"),
|
||||
ToolSuggestDisabledTool::connector(" "),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.tool_suggest,
|
||||
ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![
|
||||
ToolSuggestDisabledTool::connector("connector_calendar"),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_disabled_tools_merge_across_config_layers() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"
|
||||
[projects."{workspace_key}"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
{{ type = "connector", id = " user_connector " }},
|
||||
{{ type = "plugin", id = "shared_plugin" }},
|
||||
{{ type = "connector", id = "project_connector" }},
|
||||
]
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let project_config_dir = workspace.path().join(".codex");
|
||||
std::fs::create_dir_all(&project_config_dir)?;
|
||||
std::fs::write(
|
||||
project_config_dir.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
{ type = "connector", id = "project_connector" },
|
||||
{ type = "plugin", id = "project_plugin" },
|
||||
{ type = "plugin", id = "shared_plugin" },
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(workspace.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.tool_suggest.disabled_tools,
|
||||
vec![
|
||||
ToolSuggestDisabledTool::connector("user_connector"),
|
||||
ToolSuggestDisabledTool::plugin("shared_plugin"),
|
||||
ToolSuggestDisabledTool::connector("project_connector"),
|
||||
ToolSuggestDisabledTool::plugin("project_plugin"),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::path_utils::write_atomically;
|
||||
use anyhow::Context;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_features::FEATURES;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
@@ -10,6 +11,7 @@ use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task;
|
||||
@@ -57,6 +59,8 @@ pub enum ConfigEdit {
|
||||
RecordModelMigrationSeen { from: String, to: String },
|
||||
/// Replace the entire `[mcp_servers]` table.
|
||||
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
|
||||
/// Add a disabled tool suggestion under `[tool_suggest].disabled_tools`.
|
||||
AddToolSuggestDisabledTool(ToolSuggestDisabledTool),
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by path.
|
||||
SetSkillConfig { path: PathBuf, enabled: bool },
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by name.
|
||||
@@ -180,10 +184,13 @@ mod document_helpers {
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::InlineTable;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::Value as TomlValue;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
@@ -379,6 +386,57 @@ mod document_helpers {
|
||||
table
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool(
|
||||
value: &TomlValue,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let table = value.as_inline_table()?;
|
||||
let kind = match table.get("type").and_then(TomlValue::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlValue::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool_table(
|
||||
table: &TomlTable,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let kind = match table.get("type").and_then(TomlItem::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlItem::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tool_suggest_disabled_tools_value(
|
||||
disabled_tools: &[ToolSuggestDisabledTool],
|
||||
) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for disabled_tool in disabled_tools {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert(
|
||||
"type",
|
||||
match disabled_tool.kind {
|
||||
ToolSuggestDiscoverableType::Connector => "connector",
|
||||
ToolSuggestDiscoverableType::Plugin => "plugin",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
table.insert("id", disabled_tool.id.clone().into());
|
||||
array.push(table);
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_iter<I>(iter: I) -> TomlItem
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
@@ -552,6 +610,9 @@ impl ConfigDocument {
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
|
||||
ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => {
|
||||
Ok(self.add_tool_suggest_disabled_tool(disabled_tool))
|
||||
}
|
||||
ConfigEdit::SetSkillConfig { path, enabled } => {
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled))
|
||||
}
|
||||
@@ -590,6 +651,41 @@ impl ConfigDocument {
|
||||
self.remove(&resolved)
|
||||
}
|
||||
|
||||
fn add_tool_suggest_disabled_tool(&mut self, disabled_tool: &ToolSuggestDisabledTool) -> bool {
|
||||
let disabled_tools_item = self
|
||||
.doc
|
||||
.get("tool_suggest")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.and_then(|table| table.get("disabled_tools"));
|
||||
let existing_from_array = disabled_tools_item
|
||||
.and_then(|item| item.as_value())
|
||||
.and_then(|value| value.as_array())
|
||||
.into_iter()
|
||||
.flat_map(|array| array.iter())
|
||||
.filter_map(document_helpers::parse_tool_suggest_disabled_tool);
|
||||
let existing_from_tables = disabled_tools_item
|
||||
.and_then(|item| match item {
|
||||
TomlItem::ArrayOfTables(array) => Some(array),
|
||||
_ => None,
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|array| array.iter())
|
||||
.filter_map(document_helpers::parse_tool_suggest_disabled_tool_table);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let disabled_tools = existing_from_array
|
||||
.chain(existing_from_tables)
|
||||
.chain(std::iter::once(disabled_tool.clone()))
|
||||
.filter_map(|disabled_tool| disabled_tool.normalized())
|
||||
.filter(|disabled_tool| seen.insert(disabled_tool.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
self.write_value(
|
||||
Scope::Global,
|
||||
&["tool_suggest", "disabled_tools"],
|
||||
document_helpers::tool_suggest_disabled_tools_value(&disabled_tools),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_owned(&mut self, segments: &[String]) -> bool {
|
||||
self.remove(segments)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_config::types::OtelConfig;
|
||||
use codex_config::types::OtelConfigToml;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_config::types::TuiNotificationSettings;
|
||||
@@ -95,6 +96,7 @@ use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -1416,10 +1418,29 @@ pub struct AgentRoleConfig {
|
||||
pub nickname_candidates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig {
|
||||
let discoverables = config_toml
|
||||
.tool_suggest
|
||||
.as_ref()
|
||||
fn resolve_tool_suggest_config(
|
||||
config_toml: &ConfigToml,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> ToolSuggestConfig {
|
||||
resolve_tool_suggest_config_from_config(config_toml.tool_suggest.as_ref(), config_layer_stack)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_tool_suggest_config_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> ToolSuggestConfig {
|
||||
let tool_suggest = config_layer_stack
|
||||
.effective_config()
|
||||
.get("tool_suggest")
|
||||
.cloned()
|
||||
.and_then(|value| value.try_into::<ToolSuggestConfig>().ok());
|
||||
resolve_tool_suggest_config_from_config(tool_suggest.as_ref(), config_layer_stack)
|
||||
}
|
||||
|
||||
fn resolve_tool_suggest_config_from_config(
|
||||
tool_suggest: Option<&ToolSuggestConfig>,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> ToolSuggestConfig {
|
||||
let discoverables = tool_suggest
|
||||
.into_iter()
|
||||
.flat_map(|tool_suggest| tool_suggest.discoverables.iter())
|
||||
.filter_map(|discoverable| {
|
||||
@@ -1434,8 +1455,47 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut seen_disabled_tools = HashSet::new();
|
||||
let mut disabled_tools = Vec::new();
|
||||
let mut add_disabled_tool = |disabled_tool: ToolSuggestDisabledTool| {
|
||||
if let Some(disabled_tool) = disabled_tool.normalized()
|
||||
&& seen_disabled_tools.insert(disabled_tool.clone())
|
||||
{
|
||||
disabled_tools.push(disabled_tool);
|
||||
}
|
||||
};
|
||||
|
||||
ToolSuggestConfig { discoverables }
|
||||
let layers = config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
);
|
||||
if layers.is_empty() {
|
||||
for disabled_tool in tool_suggest
|
||||
.into_iter()
|
||||
.flat_map(|tool_suggest| tool_suggest.disabled_tools.iter().cloned())
|
||||
{
|
||||
add_disabled_tool(disabled_tool);
|
||||
}
|
||||
} else {
|
||||
for layer in layers {
|
||||
let Some(tool_suggest) = layer
|
||||
.config
|
||||
.get("tool_suggest")
|
||||
.cloned()
|
||||
.and_then(|value| value.try_into::<ToolSuggestConfig>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for disabled_tool in tool_suggest.disabled_tools {
|
||||
add_disabled_tool(disabled_tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolSuggestConfig {
|
||||
discoverables,
|
||||
disabled_tools,
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_store_config(
|
||||
@@ -1840,7 +1900,7 @@ impl Config {
|
||||
.clone(),
|
||||
None => ConfigProfile::default(),
|
||||
};
|
||||
let tool_suggest = resolve_tool_suggest_config(&cfg);
|
||||
let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack);
|
||||
let feature_overrides = FeatureOverrides {
|
||||
include_apply_patch_tool: include_apply_patch_tool_override,
|
||||
web_search_request: override_tools_web_search_request,
|
||||
|
||||
@@ -419,6 +419,14 @@ async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
|
||||
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
|
||||
.map(|discoverable| discoverable.id.clone()),
|
||||
);
|
||||
let disabled_connector_ids = config
|
||||
.tool_suggest
|
||||
.disabled_tools
|
||||
.iter()
|
||||
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector)
|
||||
.map(|disabled_tool| disabled_tool.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str()));
|
||||
connector_ids
|
||||
}
|
||||
|
||||
|
||||
@@ -1112,6 +1112,35 @@ discoverables = [
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_connector_ids_exclude_disabled_tool_suggestions() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "connector", id = "connector_calendar" },
|
||||
{ type = "connector", id = "connector_gmail" }
|
||||
]
|
||||
disabled_tools = [
|
||||
{ type = "connector", id = "connector_calendar" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
tool_suggest_connector_ids(&config).await,
|
||||
HashSet::from(["connector_gmail".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_connectors(
|
||||
|
||||
@@ -43,6 +43,13 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin)
|
||||
.map(|discoverable| discoverable.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let disabled_plugin_ids = config
|
||||
.tool_suggest
|
||||
.disabled_tools
|
||||
.iter()
|
||||
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin)
|
||||
.map(|disabled_tool| disabled_tool.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?
|
||||
@@ -56,6 +63,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
if plugin.installed
|
||||
|| disabled_plugin_ids.contains(plugin.id.as_str())
|
||||
|| (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
|
||||
&& !configured_plugin_ids.contains(plugin.id.as_str()))
|
||||
{
|
||||
|
||||
@@ -230,6 +230,31 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
|
||||
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
{ type = "plugin", id = "slack@openai-curated" }
|
||||
]
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::build_available_skills;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
use crate::config::ManagedFeatures;
|
||||
use crate::config::resolve_tool_suggest_config_from_layer_stack;
|
||||
use crate::connectors;
|
||||
use crate::context::ApprovedCommandPrefixSaved;
|
||||
use crate::context::AppsInstructions;
|
||||
@@ -1407,6 +1408,8 @@ impl Session {
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
config.tool_suggest =
|
||||
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
self.services.skills_manager.clear_cache();
|
||||
self.services.plugins_manager.clear_cache();
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_config::NetworkDomainPermissionsToml;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::Sourced;
|
||||
use codex_config::loader::project_trust_key;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
@@ -1155,6 +1156,35 @@ async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
assert_eq!(app.destructive_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_updates_effective_tool_suggest_config() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&config_toml_path,
|
||||
r#"[tool_suggest]
|
||||
disabled_tools = [
|
||||
{ type = "connector", id = " calendar " },
|
||||
{ type = "plugin", id = "slack@openai-curated" },
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write user config");
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
|
||||
let config = session.get_config().await;
|
||||
assert_eq!(
|
||||
config.tool_suggest.disabled_tools,
|
||||
vec![
|
||||
ToolSuggestDisabledTool::connector("calendar"),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
|
||||
let connectors = vec![
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::DiscoverableToolAction;
|
||||
use codex_tools::DiscoverableToolType;
|
||||
use codex_tools::TOOL_SUGGEST_PERSIST_ALWAYS_VALUE;
|
||||
use codex_tools::TOOL_SUGGEST_PERSIST_KEY;
|
||||
use codex_tools::TOOL_SUGGEST_TOOL_NAME;
|
||||
use codex_tools::ToolSuggestArgs;
|
||||
use codex_tools::ToolSuggestResult;
|
||||
@@ -14,8 +18,11 @@ use codex_tools::build_tool_suggestion_elicitation_request;
|
||||
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
|
||||
use codex_tools::verified_connector_suggestion_completed;
|
||||
use rmcp::model::RequestId;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
@@ -123,6 +130,9 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
let response = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
.await;
|
||||
if let Some(response) = response.as_ref() {
|
||||
maybe_persist_tool_suggest_disable(&session, &turn, &tool, response).await;
|
||||
}
|
||||
let user_confirmed = response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
@@ -158,6 +168,63 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_persist_tool_suggest_disable(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
tool: &DiscoverableTool,
|
||||
response: &ElicitationResponse,
|
||||
) {
|
||||
if !tool_suggest_response_requests_persistent_disable(response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = persist_tool_suggest_disable(&turn.config.codex_home, tool).await {
|
||||
warn!(
|
||||
error = %err,
|
||||
tool_id = tool.id(),
|
||||
"failed to persist disabled tool suggestion"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
fn tool_suggest_response_requests_persistent_disable(response: &ElicitationResponse) -> bool {
|
||||
if response.action != ElicitationAction::Decline {
|
||||
return false;
|
||||
}
|
||||
|
||||
response
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|meta| meta.get(TOOL_SUGGEST_PERSIST_KEY))
|
||||
.and_then(Value::as_str)
|
||||
== Some(TOOL_SUGGEST_PERSIST_ALWAYS_VALUE)
|
||||
}
|
||||
|
||||
async fn persist_tool_suggest_disable(
|
||||
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
|
||||
tool: &DiscoverableTool,
|
||||
) -> anyhow::Result<()> {
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::AddToolSuggestDisabledTool(
|
||||
disabled_tool_suggestion(tool),
|
||||
)])
|
||||
.apply()
|
||||
.await
|
||||
}
|
||||
|
||||
fn disabled_tool_suggestion(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
ToolSuggestDisabledTool::connector(connector.id.as_str())
|
||||
}
|
||||
DiscoverableTool::Plugin(plugin) => ToolSuggestDisabledTool::plugin(plugin.id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_tool_suggestion_completed(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
|
||||
@@ -5,8 +5,20 @@ use crate::plugins::test_support::load_plugins_config;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use crate::plugins::test_support::write_plugins_feature_config;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -44,3 +56,155 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
|
||||
&plugins_manager,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_response_persists_only_decline_always_mode() {
|
||||
assert!(tool_suggest_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
|
||||
}
|
||||
));
|
||||
assert!(!tool_suggest_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
|
||||
}
|
||||
));
|
||||
assert!(!tool_suggest_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: "session" })),
|
||||
}
|
||||
));
|
||||
assert!(!tool_suggest_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_tool_suggest_disable_writes_connector_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
|
||||
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::connector("connector_calendar")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_tool_suggest_disable_writes_plugin_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
name: "Slack".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}));
|
||||
|
||||
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist plugin disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::plugin("slack@openai-curated")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_tool_suggest_disable_dedupes_existing_disabled_tools() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "plugin", id = "sample@openai-curated" }
|
||||
]
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " connector_calendar "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = "connector_calendar"
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "plugin"
|
||||
id = "slack@openai-curated"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: vec![ToolSuggestDiscoverable {
|
||||
kind: ToolSuggestDiscoverableType::Plugin,
|
||||
id: "sample@openai-curated".to_string(),
|
||||
}],
|
||||
disabled_tools: vec![
|
||||
ToolSuggestDisabledTool::connector("connector_calendar"),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn connector_tool(id: &str, name: &str) -> DiscoverableTool {
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -140,6 +140,8 @@ pub use tool_spec::create_local_shell_tool;
|
||||
pub use tool_spec::create_tools_json_for_responses_api;
|
||||
pub use tool_spec::create_web_search_tool;
|
||||
pub use tool_suggest::TOOL_SUGGEST_APPROVAL_KIND_VALUE;
|
||||
pub use tool_suggest::TOOL_SUGGEST_PERSIST_ALWAYS_VALUE;
|
||||
pub use tool_suggest::TOOL_SUGGEST_PERSIST_KEY;
|
||||
pub use tool_suggest::ToolSuggestArgs;
|
||||
pub use tool_suggest::ToolSuggestMeta;
|
||||
pub use tool_suggest::ToolSuggestResult;
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::DiscoverableToolAction;
|
||||
use crate::DiscoverableToolType;
|
||||
|
||||
pub const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
|
||||
pub const TOOL_SUGGEST_PERSIST_KEY: &str = "persist";
|
||||
pub const TOOL_SUGGEST_PERSIST_ALWAYS_VALUE: &str = "always";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ToolSuggestArgs {
|
||||
@@ -37,6 +39,7 @@ pub struct ToolSuggestResult {
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ToolSuggestMeta<'a> {
|
||||
pub codex_approval_kind: &'static str,
|
||||
pub persist: &'static str,
|
||||
pub tool_type: DiscoverableToolType,
|
||||
pub suggest_type: DiscoverableToolAction,
|
||||
pub suggest_reason: &'a str,
|
||||
@@ -111,6 +114,7 @@ fn build_tool_suggestion_meta<'a>(
|
||||
) -> ToolSuggestMeta<'a> {
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
|
||||
tool_type,
|
||||
suggest_type: action_type,
|
||||
suggest_reason,
|
||||
|
||||
@@ -48,6 +48,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar",
|
||||
@@ -104,6 +105,7 @@ fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
|
||||
tool_type: DiscoverableToolType::Plugin,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Use the sample plugin's skills and MCP server",
|
||||
@@ -138,6 +140,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
|
||||
meta,
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
persist: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Find and reference emails from your inbox",
|
||||
|
||||
@@ -48,6 +48,16 @@ Use `$` in the composer to insert a ChatGPT connector; the popover lists accessi
|
||||
apps. The `/apps` command lists available and installed apps. Connected apps appear first
|
||||
and are labeled as connected; others are marked as can be installed.
|
||||
|
||||
Codex stores "never show again" choices for tool suggestions in `config.toml`:
|
||||
|
||||
```toml
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
{ type = "plugin", id = "slack@openai-curated" },
|
||||
{ type = "connector", id = "connector_google_calendar" },
|
||||
]
|
||||
```
|
||||
|
||||
## Notify
|
||||
|
||||
Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings:
|
||||
|
||||
Reference in New Issue
Block a user