mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Support model catalog overrides for plugin guidance (#58894)
## What changed - Add `model_messages.plugins` overrides for generic usage guidance, selected-plugin hints, and recommendation introductions, with independent fallback to bundled wording for missing or invalid fields. - Validate text limits and required template placeholders while keeping plugin identities, available capabilities, and eligibility controlled by the client. - Track generic guidance changes across model switches and session resume, emitting replacement or retirement notices and supporting legacy snapshots and retained history. ## Testing Add unit and scenario coverage for sparse overrides, literal text preservation, template validation, UTF-8 size bounds, capability filtering, recommendation fallback, and guidance recovery after resume. GitOrigin-RevId: 148b658c2d8490c9d0dc208addbee1be5342a5f5
This commit is contained in:
@@ -17,11 +17,13 @@ pub(crate) fn build_plugin_injections(
|
||||
mcp_tools: &[ToolInfo],
|
||||
available_connectors: &[connectors::AppInfo],
|
||||
features: &Features,
|
||||
model_messages: codex_prompts::ResolvedModelMessages<'_>,
|
||||
) -> Vec<ResponseItem> {
|
||||
if mentioned_plugins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let messages = model_messages.selected_plugin();
|
||||
// Turn each explicit plugin mention with available capabilities into a developer hint.
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
@@ -57,6 +59,7 @@ pub(crate) fn build_plugin_injections(
|
||||
&available_mcp_servers,
|
||||
&available_apps,
|
||||
features,
|
||||
&messages,
|
||||
)
|
||||
.map(PluginInstructions::new)
|
||||
.map(ContextualUserFragment::into)
|
||||
|
||||
@@ -5,17 +5,20 @@ use crate::context::ContextualUserFragment;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_prompts::ResolvedSelectedPluginMessages;
|
||||
#[cfg(test)]
|
||||
use codex_protocol_test_support::ContentItemTestExt;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
|
||||
const MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES: usize = 4 * 1024;
|
||||
const TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX: &str =
|
||||
"\n- Additional plugin capabilities omitted to fit the context limit.";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option<String> {
|
||||
(!plugins.is_empty()).then(|| AvailablePluginsInstructions.render().expect_text())
|
||||
(!plugins.is_empty()).then(|| {
|
||||
AvailablePluginsInstructions::default()
|
||||
.render()
|
||||
.expect_text()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_explicit_plugin_instructions(
|
||||
@@ -23,6 +26,7 @@ pub(crate) fn render_explicit_plugin_instructions(
|
||||
available_mcp_servers: &[String],
|
||||
available_apps: &[String],
|
||||
features: &Features,
|
||||
messages: &ResolvedSelectedPluginMessages,
|
||||
) -> Option<String> {
|
||||
if features.enabled(Feature::SimplifiedSelectionHints) {
|
||||
return (plugin.has_skills || !available_apps.is_empty() || !available_mcp_servers.is_empty())
|
||||
@@ -37,20 +41,14 @@ pub(crate) fn render_explicit_plugin_instructions(
|
||||
});
|
||||
}
|
||||
|
||||
let mut lines = vec![format!(
|
||||
"Capabilities from the `{}` plugin:",
|
||||
plugin.display_name
|
||||
)];
|
||||
let mut lines = vec![
|
||||
messages
|
||||
.intro
|
||||
.replace("{{ plugin_name }}", &plugin.display_name),
|
||||
];
|
||||
|
||||
if !available_apps.is_empty() {
|
||||
lines.push(
|
||||
concat!(
|
||||
"- For the user request that explicitly selected this plugin, and only for that ",
|
||||
"request, if `tool_search` is available and an app from this plugin may help, ",
|
||||
"search for its tools before falling back to unrelated or built-in tools."
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(messages.search_tools.clone());
|
||||
}
|
||||
|
||||
if plugin.has_skills {
|
||||
@@ -58,42 +56,55 @@ pub(crate) fn render_explicit_plugin_instructions(
|
||||
.plugin_namespace
|
||||
.as_deref()
|
||||
.unwrap_or(plugin.display_name.as_str());
|
||||
lines.push(format!(
|
||||
"- Skills from this plugin are prefixed with `{skill_namespace}:`."
|
||||
));
|
||||
lines.push(
|
||||
messages
|
||||
.skill_namespace
|
||||
.replace("{{ skill_namespace }}", skill_namespace),
|
||||
);
|
||||
}
|
||||
|
||||
if !available_apps.is_empty() {
|
||||
lines.push(format!(
|
||||
"- Apps from this plugin available in this session: {}.",
|
||||
available_apps
|
||||
.iter()
|
||||
.map(|app| format!("`{app}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
lines.push(
|
||||
messages.apps.replace(
|
||||
"{{ apps }}",
|
||||
&available_apps
|
||||
.iter()
|
||||
.map(|app| format!("`{app}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !available_mcp_servers.is_empty() {
|
||||
lines.push(format!(
|
||||
"- MCP servers from this plugin available in this session: {}.",
|
||||
available_mcp_servers
|
||||
.iter()
|
||||
.map(|server| format!("`{server}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
lines.push(
|
||||
messages.mcp_servers.replace(
|
||||
"{{ mcp_servers }}",
|
||||
&available_mcp_servers
|
||||
.iter()
|
||||
.map(|server| format!("`{server}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
|
||||
lines.push(messages.closing.clone());
|
||||
|
||||
// The separator belongs to the harness; preserve the catalog's exact bytes after it.
|
||||
let omitted = if messages.omitted.starts_with('\n') || messages.omitted.starts_with("\r\n") {
|
||||
messages.omitted.clone()
|
||||
} else {
|
||||
format!("\n{}", messages.omitted)
|
||||
};
|
||||
|
||||
Some(bound_explicit_plugin_instructions(
|
||||
lines.join("\n"),
|
||||
TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX,
|
||||
&omitted,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ fn explicit_plugin_instructions_use_manifest_namespace_for_skills() {
|
||||
&[],
|
||||
&[],
|
||||
&Features::default(),
|
||||
&codex_prompts::ResolvedModelMessages::bundled().selected_plugin(),
|
||||
)
|
||||
.expect("skill capability should render");
|
||||
|
||||
@@ -55,6 +56,7 @@ fn explicit_plugin_instructions_search_available_apps_before_fallback() {
|
||||
&[],
|
||||
&["Adobe".to_string()],
|
||||
&Features::default(),
|
||||
&codex_prompts::ResolvedModelMessages::bundled().selected_plugin(),
|
||||
)
|
||||
.expect("app capability should render");
|
||||
|
||||
@@ -88,6 +90,7 @@ fn explicit_plugin_instructions_are_bounded() {
|
||||
&servers,
|
||||
&apps,
|
||||
&Features::default(),
|
||||
&codex_prompts::ResolvedModelMessages::bundled().selected_plugin(),
|
||||
)
|
||||
.expect("MCP capability should render");
|
||||
|
||||
@@ -96,5 +99,59 @@ fn explicit_plugin_instructions_are_bounded() {
|
||||
assert!(rendered.contains("if `tool_search` is available"));
|
||||
assert!(rendered.contains("Skills from this plugin"));
|
||||
assert!(rendered.contains("`app-0`"));
|
||||
assert!(rendered.ends_with(TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX));
|
||||
assert!(
|
||||
rendered.ends_with(
|
||||
&codex_prompts::ResolvedModelMessages::bundled()
|
||||
.selected_plugin()
|
||||
.omitted
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_plugin_templates_insert_runtime_facts_once() {
|
||||
let mut messages = codex_prompts::ResolvedModelMessages::bundled().selected_plugin();
|
||||
messages.intro = "Selected {{ plugin_name }}.".into();
|
||||
messages.search_tools = "Search wording {{literal}}.\r\n".into();
|
||||
messages.skill_namespace = "Skills {{ skill_namespace }}.".into();
|
||||
messages.apps = "Apps {{ apps }}.".into();
|
||||
messages.mcp_servers = "Servers {{ mcp_servers }}.".into();
|
||||
messages.closing = "Closing {{literal}}.".into();
|
||||
let rendered = render_explicit_plugin_instructions(
|
||||
&PluginCapabilitySummary {
|
||||
display_name: "Name {{ apps }}".into(),
|
||||
plugin_namespace: Some("namespace{{ plugin_name }}".into()),
|
||||
has_skills: true,
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
&["server{{ apps }}".into()],
|
||||
&["app{{ mcp_servers }}".into()],
|
||||
&Features::default(),
|
||||
&messages,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"Selected Name {{ apps }}.\nSearch wording {{literal}}.\r\n\nSkills namespace{{ plugin_name }}.\nApps `app{{ mcp_servers }}`.\nServers `server{{ apps }}`.\nClosing {{literal}}."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_plugin_omission_keeps_utf8_and_the_final_item_bound() {
|
||||
let mut messages = codex_prompts::ResolvedModelMessages::bundled().selected_plugin();
|
||||
messages.omitted = "é".repeat(250);
|
||||
let rendered = render_explicit_plugin_instructions(
|
||||
&PluginCapabilitySummary {
|
||||
display_name: "plugin".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&[],
|
||||
&["🦀".repeat(2_000)],
|
||||
&Features::default(),
|
||||
&messages,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rendered.len() <= MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES);
|
||||
assert!(rendered.ends_with(&format!("\n{}", messages.omitted)));
|
||||
assert!(rendered.contains("🦀"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user