Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions codex-rs/core/src/apps/render.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_app_server_protocol::AppInfo;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;

pub(crate) fn render_apps_section() -> String {
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
if !connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
{
return None;
}

let body = format!(
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
);
format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}")
Some(format!(
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
))
}

#[cfg(test)]
mod tests {
use super::*;

fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.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,
is_enabled,
plugin_display_names: Vec::new(),
}
}

#[test]
fn omits_apps_section_without_accessible_and_enabled_apps() {
assert_eq!(render_apps_section(&[]), None);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ false
)]),
None
);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ false, /*is_enabled*/ true
)]),
None
);
}

#[test]
fn renders_apps_section_with_an_accessible_and_enabled_app() {
let rendered = render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ true,
)])
.expect("expected apps section");

assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG));
assert!(rendered.contains("## Apps (Connectors)"));
assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG));
}
}
11 changes: 10 additions & 1 deletion codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3537,7 +3537,16 @@ impl Session {
}
}
if turn_context.apps_enabled() {
developer_sections.push(render_apps_section());
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
let accessible_and_enabled_connectors =
connectors::list_accessible_and_enabled_connectors_from_manager(
&mcp_connection_manager,
&turn_context.config,
)
.await;
if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) {
developer_sections.push(apps_section);
}
}
let implicit_skills = turn_context
.turn_skills
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/core/src/connectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
)
}

pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
mcp_connection_manager: &McpConnectionManager,
config: &Config,
) -> Vec<AppInfo> {
with_app_enabled_state(
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
config,
)
.into_iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.collect()
}

pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
Expand Down
5 changes: 4 additions & 1 deletion codex-rs/core/src/tools/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@ impl ToolsConfig {
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool = model_info.supports_search_tool;
let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest);
let include_tool_suggest = include_search_tool
&& features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins);
let include_original_image_detail = can_request_original_image_detail(features, model_info);
let include_artifact_tools =
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
Expand Down
48 changes: 48 additions & 0 deletions codex-rs/core/src/tools/spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
Expand Down Expand Up @@ -2061,6 +2062,52 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
);
}

#[test]
fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info();
let discoverable_tools = Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
"Plan events and schedules.",
)]);
let available_models = Vec::new();

for disabled_feature in [Feature::Apps, Feature::Plugins] {
let mut features = Features::with_defaults();
features.enable(Feature::ToolSuggest);
for feature in [Feature::Apps, Feature::Plugins] {
if feature != disabled_feature {
features.enable(feature);
}
}

let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs_with_discoverable_tools(
&tools_config,
None,
None,
discoverable_tools.clone(),
&[],
)
.build();

assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}
}

#[test]
fn search_tool_description_handles_no_enabled_apps() {
let model_info = search_capable_model_info();
Expand Down Expand Up @@ -2205,6 +2252,7 @@ fn tool_suggest_description_lists_discoverable_tools() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.enable(Feature::ToolSuggest);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
Expand Down
Loading