Move host skill prompt injection into the skills extension (#37503)

## What changed

- Add `HostSkillsSnapshot::load_skill_prompts` so the skills extension owns reading and rendering selected host skills.
- Keep explicit invocation telemetry, unreadable-skill warnings, plugin prompt ordering, and suppression of host prompts superseded by provider prompts.
- Remove the legacy prompt injection and fragment implementation from `codex-core-skills`.

## Testing

- Cover prompt truncation without an installed skills extension, skill/plugin ordering, selective provider supersession, unreadable skills, and contextual fragment detection.

GitOrigin-RevId: 9c8b84ad54b90d174abc287ab49cc5a231e9e1aa
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-07 21:04:16 +00:00
1 parent 8e4b10446e
commit beac16cccd
22 files changed
+553 -299

No files matched your search

+2 -2
View File
@@ -2,8 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::HostSkillsSnapshot;
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use crate::InjectedHostSkillPrompts;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
@@ -61,6 +60,7 @@ use crate::selection::collect_explicit_skill_mentions;
use crate::shadow_selection_experiment::ShadowSelectionExperiment;
use crate::sources::SkillProviders;
use crate::state::ExecutorSkillsStepState;
use crate::state::HostSkillsCatalogInWorldState;
use crate::state::HostSkillsStepState;
use crate::state::SkillsSessionState;
use crate::state::SkillsThreadState;
+97
View File
@@ -0,0 +1,97 @@
use std::collections::HashSet;
use codex_extension_api::ContextualUserFragment;
use codex_skills::SkillMetadata;
use codex_skills::normalize_skill_path;
use crate::HostSkillsSnapshot;
use crate::fragments::SkillInstructions;
use crate::render::truncate_main_prompt_contents;
/// Host skill prompts already supplied or superseded by an extension.
///
/// Core preserves its host skill invocation lifecycle while avoiding duplicate
/// prompts and retaining executor/orchestrator skill precedence.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InjectedHostSkillPrompts {
paths: HashSet<String>,
}
impl InjectedHostSkillPrompts {
pub fn insert_path(&mut self, path: impl Into<String>) {
let path = path.into();
self.paths.insert(normalize_host_skill_path(&path));
self.paths.insert(path);
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn contains_path(&self, path: &str) -> bool {
self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path))
}
}
/// Prompt fragments and read outcomes for a set of selected host skills.
pub struct HostSkillPrompts {
pub fragments: Vec<Box<dyn ContextualUserFragment + Send>>,
pub injected: Vec<SkillMetadata>,
pub warnings: Vec<String>,
}
fn normalize_host_skill_path(path: &str) -> String {
normalize_skill_path(path).replace('\\', "/")
}
impl HostSkillsSnapshot {
/// Reads selected host skills and builds their model-visible prompt fragments.
///
/// Core calls this directly, including for hosts without an installed skills extension.
#[tracing::instrument(
level = "trace",
skip_all,
fields(selected_skill_count = selected_skills.len())
)]
pub async fn load_skill_prompts(&self, selected_skills: &[SkillMetadata]) -> HostSkillPrompts {
let mut prompts = HostSkillPrompts {
fragments: Vec::with_capacity(selected_skills.len()),
injected: Vec::with_capacity(selected_skills.len()),
warnings: Vec::new(),
};
for skill in selected_skills {
match self.read_skill_text(skill).await {
Ok(contents) => {
let (contents, truncated) = if self.outcome().is_agent_plugin_skill(skill) {
truncate_main_prompt_contents(&contents)
} else {
(contents, false)
};
if truncated {
prompts.warnings.push(format!(
"Skill `{}` exceeded the main prompt context limit and was truncated.",
skill.name
));
}
prompts.fragments.push(Box::new(SkillInstructions {
name: skill.name.clone(),
path: skill.path_to_skills_md.to_string_lossy().into_owned(),
contents,
resource_access: None,
}));
prompts.injected.push(skill.clone());
}
Err(err) => {
prompts.warnings.push(format!(
"Failed to load skill {} at {}: {err:#}",
skill.name,
skill.path_to_skills_md.display()
));
}
}
}
prompts
}
}
+10
View File
@@ -6,6 +6,7 @@ mod dynamic_skill_selector;
mod extension;
mod fragments;
mod host_aliases;
mod host_prompt;
mod host_roots;
mod host_service;
mod host_snapshot;
@@ -26,6 +27,8 @@ pub use config::SkillsExtensionConfig;
pub use extension::install;
pub use extension::install_with_providers;
pub use extension::install_with_providers_and_metrics;
pub use host_prompt::HostSkillPrompts;
pub use host_prompt::InjectedHostSkillPrompts;
pub use host_service::HostSkillsLoadInput;
pub use host_service::HostSkillsService;
pub use host_service::bundled_skills_enabled_from_stack;
@@ -37,3 +40,10 @@ pub use provider::OrchestratorSkillProvider;
pub use provider::SkillProvider;
pub use sources::SkillProviderSource;
pub use sources::SkillProviders;
/// Recognizes persisted explicit skill prompts without exposing their fragment implementation.
pub fn is_skill_prompt_fragment(text: &str) -> bool {
<fragments::SkillInstructions as codex_extension_api::ContextualUserFragment>::matches_text(
text,
)
}
+10
View File
@@ -20,6 +20,16 @@ use crate::catalog_prompt::render_available_skills_body;
use crate::loader::HostSkillRoot;
use crate::loader::load_and_merge_host_skill_roots;
#[test]
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
let (bounded, truncated) = truncate_main_prompt_contents(&contents);
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
assert_eq!(truncated, true);
}
fn entry(name: &str, description: &str, short_description: Option<&str>) -> SkillCatalogEntry {
entry_with_path(
name,
+1 -1
View File
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use codex_core_skills::injection::extract_tool_mentions;
use codex_protocol::user_input::UserInput;
use codex_skills::extract_tool_mentions;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
+3
View File
@@ -384,6 +384,9 @@ pub(crate) struct SkillsTurnState {
pub(crate) main_prompts_injected: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct HostSkillsCatalogInWorldState;
#[derive(Clone, Debug, Default)]
pub(crate) struct ExecutorSkillsStepState(pub(crate) SkillCatalog);
@@ -1,6 +1,5 @@
use std::sync::Arc;
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionWarning;
@@ -22,6 +21,7 @@ use crate::render_observability::record_catalog_render;
use crate::sources::SkillProviders;
use crate::state::EmittedCatalogBudgetWarnings;
use crate::state::ExecutorSkillsStepState;
use crate::state::HostSkillsCatalogInWorldState;
use crate::state::HostSkillsStepState;
use crate::state::SkillsSessionState;
use crate::state::SkillsThreadState;