Make plugin guidance react to environment readiness (#30223)

## Why

Generic plugin guidance is currently emitted only with initial context
from host plugin state. An executor-selected plugin can become available
later in the same turn, making its skills and tools usable without ever
telling the model how plugin capabilities should be used.

## What

- project every ready selected plugin package, including skill-only
plugins
- carry plugin availability with the exact MCP runtime projection while
preserving MCP manager reuse when servers and connectors are unchanged
- move generic plugin guidance from the static initial-context path into
persisted World State
- recognize legacy and retained plugin fragments so resume and
compaction do not duplicate guidance

## Testing

- `just test -p codex-mcp-extension`
- `just test -p codex-core plugins_instructions`
- `just test -p codex-core
plugin_availability_change_reuses_the_mcp_manager`
- `just test -p codex-app-server --test all selected_capabilit`
This commit is contained in:
sayan-oai
2026-07-06 07:05:40 -07:00
committed by GitHub
parent be33f80bc6
commit 9c5be7e1d5
21 changed files with 443 additions and 94 deletions
@@ -1,25 +1,10 @@
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AvailablePluginsInstructions {
plugins: Vec<PluginCapabilitySummary>,
}
impl AvailablePluginsInstructions {
pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option<Self> {
if plugins.is_empty() {
return None;
}
Some(Self {
plugins: plugins.to_vec(),
})
}
}
pub(crate) struct AvailablePluginsInstructions;
impl ContextualUserFragment for AvailablePluginsInstructions {
fn role(&self) -> &'static str {
+15 -3
View File
@@ -1,5 +1,6 @@
mod agents_md;
mod environment;
mod plugins_instructions;
use crate::context::ContextualUserFragment;
use codex_extension_api::PreviousWorldStateSection;
@@ -17,6 +18,7 @@ use std::fmt;
pub(crate) use agents_md::AgentsMdState;
pub(crate) use environment::EnvironmentsState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
trait ErasedWorldStateSection: Send + Sync {
fn snapshot(&self) -> Option<Value>;
@@ -62,11 +64,11 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
}
fn has_retained_fragment_matcher(&self) -> bool {
false
S::has_retained_fragment_matcher()
}
fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool {
false
fn matches_retained_fragment(&self, role: &str, text: &str) -> bool {
S::matches_retained_fragment(role, text)
}
fn render_diff(
@@ -183,6 +185,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
false
}
/// Whether retained history must still contain this section's rendered fragment.
fn has_retained_fragment_matcher() -> bool {
false
}
/// Recognizes this section's rendered fragment in retained model history.
fn matches_retained_fragment(_role: &str, _text: &str) -> bool {
false
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
@@ -0,0 +1,55 @@
use super::PreviousSectionState;
use super::WorldStateSection;
use crate::context::AvailablePluginsInstructions;
use crate::context::ContextualUserFragment;
/// Whether generic plugin usage guidance should be visible to the model.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct PluginsInstructionsState {
available: bool,
}
impl PluginsInstructionsState {
pub(crate) fn new(available: bool) -> Self {
Self { available }
}
}
impl WorldStateSection for PluginsInstructionsState {
const ID: &'static str = "plugins_instructions";
type Snapshot = bool;
fn snapshot(&self) -> Self::Snapshot {
self.available
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && AvailablePluginsInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
true
}
fn matches_retained_fragment(role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
if !self.available
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|| matches!(previous, PreviousSectionState::Unknown)
{
return None;
}
Some(Box::new(AvailablePluginsInstructions))
}
}
#[cfg(test)]
#[path = "plugins_instructions_tests.rs"]
mod tests;
@@ -0,0 +1,72 @@
use super::*;
use crate::context::ContextualUserFragment;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
fn render(
state: PluginsInstructionsState,
previous: PreviousSectionState<'_, bool>,
) -> Vec<String> {
state
.render_diff(previous)
.into_iter()
.map(|fragment| fragment.render())
.collect()
}
#[test]
fn renders_only_when_plugins_become_available() {
let unavailable = PluginsInstructionsState::new(/*available*/ false);
let available = PluginsInstructionsState::new(/*available*/ true);
let false_snapshot = false;
let true_snapshot = true;
assert_eq!(
render(unavailable, PreviousSectionState::Absent),
Vec::<String>::new()
);
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
assert_eq!(
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
1
);
assert_eq!(
render(available, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
assert_eq!(
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
}
#[test]
fn legacy_guidance_is_not_injected_again() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
assert!(
world_state
.render_history_diff(/*previous*/ None, &[legacy])
.is_empty()
);
}
#[test]
fn persisted_guidance_is_restored_only_when_missing_from_history() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
let snapshot = world_state.snapshot();
let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
assert_eq!(
world_state.render_history_diff(Some(&snapshot), &[]).len(),
1
);
assert!(
world_state
.render_history_diff(Some(&snapshot), &[retained])
.is_empty()
);
}