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:
goodman-oai
2026-11-02 14:06:17 +00:00
committed by copyberry
parent 1e8b536dc7
commit aef7d276a4
39 changed files with 3326 additions and 107 deletions
+3
View File
@@ -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)
+47 -36
View File
@@ -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,
))
}
+58 -1
View File
@@ -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("🦀"));
}