mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user