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
+9
View File
@@ -41,6 +41,11 @@ mod memento_v2;
pub use memento_v2::ContextManagementToolMessages;
pub use memento_v2::HistoryToolMessages;
pub use memento_v2::NotesToolMessages;
mod plugins;
pub use plugins::GenericPluginMessages;
pub use plugins::PluginMessages;
pub use plugins::PluginRecommendationMessages;
pub use plugins::SelectedPluginMessages;
#[path = "openai_models/guardian.rs"]
mod guardian;
pub use guardian::GuardianModelPolicy;
@@ -583,6 +588,9 @@ pub struct ModelMessages {
/// Optional model-owned Apps wording, transported without runtime interpretation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<AppsMessages>,
/// Model-owned plugin wording; runtime capability facts and eligibility remain client-owned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<PluginMessages>,
/// Developer guidance after a content-filter block. Missing, null, blank, or values over
/// 512 UTF-8 bytes use the bundled guidance.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1764,6 +1772,7 @@ mod tests {
apps: Some(AppsMessages {
usage_instructions: Some("Literal Apps {{guidance}}".to_string()),
}),
plugins: None,
content_filter_guidance: Some("Offer a permitted alternative.".to_string()),
persistent_instructions: Some("Persistent catalog instructions".to_string()),
tools: Some(ToolMessages {
@@ -0,0 +1,66 @@
//! Sparse model-catalog wording for generic, selected, and recommended plugins.
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Sparse plugin wording families, independent of runtime availability and authorization.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct PluginMessages {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generic: Option<GenericPluginMessages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected: Option<SelectedPluginMessages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommendations: Option<PluginRecommendationMessages>,
}
/// Literal generic plugin wording. Missing, null, blank, or invalid values use bundled text.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct GenericPluginMessages {
/// Literal text, up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
/// Up to 2,000 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_instructions: Option<String>,
}
/// Selected-plugin wording. Fact-bearing fields require exactly one documented placeholder.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct SelectedPluginMessages {
/// Up to 500 UTF-8 bytes. Selected intro requires `{{ plugin_name }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
/// Up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_tools: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ skill_namespace }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_namespace: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ apps }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ mcp_servers }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<String>,
/// Up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closing: Option<String>,
/// Nonblank omission notice, up to 500 UTF-8 bytes, including any desired separator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omitted: Option<String>,
}
/// Literal recommendation introduction; plugin identities remain supplied by the harness.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct PluginRecommendationMessages {
/// Literal text, up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
}
#[cfg(test)]
#[path = "plugins_tests.rs"]
mod tests;
@@ -0,0 +1,34 @@
#![cfg(test)]
use crate::openai_models::ModelMessages;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn plugin_messages_preserve_sparse_nested_and_literal_wire_values() {
for plugins in [
serde_json::Value::Null,
json!({}),
json!({"generic": {}, "selected": {}, "recommendations": {}}),
json!({
"generic": {"intro": "", "usage_instructions": " \r\n\t"},
"selected": {
"intro": " {{ plugin_name }}\r\n",
"search_tools": "literal {{braces}}",
"skill_namespace": "{{ skill_namespace }}",
"apps": "{{ apps }}",
"mcp_servers": "{{ mcp_servers }}",
"closing": "é".repeat(251),
"omitted": "omitted"
},
"recommendations": {"intro": "recommendations"}
}),
] {
let messages: ModelMessages = serde_json::from_value(json!({"plugins": plugins})).unwrap();
let mut expected = serde_json::to_value(ModelMessages::default()).unwrap();
if !plugins.is_null() {
expected["plugins"] = plugins;
}
assert_eq!(serde_json::to_value(messages).unwrap(), expected);
}
}