mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Implement v1 skills extension prompt injection (#26167)
## Why The skills extension needs a real turn-time path before host, executor, or remote skills can be routed through it. The previous code was mostly a placeholder catalog/provider sketch, so there was no bounded available-skills fragment, no source-owned `SKILL.md` read, and no place for warnings or per-turn selection state to live. This PR makes `ext/skills` the authority-preserving flow for listing candidate skills and injecting only explicitly selected main prompts, without adding more of that logic to `codex-core`. ## What changed - Expands catalog entries with `main_prompt`, display path, short description, dependency metadata, enabled/prompt visibility flags, and authority/package-aware read requests. - Replaces the placeholder `providers/*` modules with `SkillProviderSource` and `SkillProviders`, routing list/read/search calls by source kind and surfacing provider failures as warnings. - Adds bounded available-skills rendering and `SKILL.md` main-prompt truncation before the fragments enter model context. - Resolves explicit skill selections from structured `UserInput::Skill`, skill-file mentions, `skill://...` paths, and plain `$skill` text mentions, then reads selected prompts through their owning provider. - Stores mutable per-thread skills config and per-turn catalog/selection/warning state. - Adds `install_with_providers` so tests and future host wiring can supply concrete providers. ## Testing - Not run locally. - Added `codex-rs/ext/skills/tests/skills_extension.rs` coverage for available-catalog injection, selected prompt injection through the owning provider, and prompt-hidden skills that remain invokable.
This commit is contained in:
15 files changed
+1001
-335
No files matched your search
@@ -1,42 +1,49 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core_skills::SkillInstructions;
|
||||
use codex_core_skills::injection::SkillInjection;
|
||||
use codex_extension_api::ConfigContributor;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ContextualUserFragment;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::TurnInputContext;
|
||||
use codex_extension_api::TurnInputContributor;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalogEntry;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillSourceKind;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::providers::SkillProviders;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::render::available_skills_fragment;
|
||||
use crate::render::truncate_main_prompt_contents;
|
||||
use crate::selection::collect_explicit_skill_mentions;
|
||||
use crate::sources::SkillProviders;
|
||||
use crate::state::SkillsExtensionConfig;
|
||||
use crate::state::SkillsThreadState;
|
||||
use crate::state::SkillsTurnState;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone)]
|
||||
struct SkillsExtension {
|
||||
providers: SkillProviders,
|
||||
event_sink: Arc<dyn ExtensionEventSink>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ThreadLifecycleContributor<Config> for SkillsExtension {
|
||||
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
|
||||
// TODO(skills-extension): this is only the thread-level config snapshot.
|
||||
// Skills are loaded per turn today because cwd, plugin roots, config
|
||||
// layers, and the primary environment filesystem can change between
|
||||
// turns. The TurnInputContributor below owns per-turn catalog
|
||||
// resolution.
|
||||
input
|
||||
.thread_store
|
||||
.insert(SkillsExtensionConfig::from_config(input.config));
|
||||
.insert(SkillsThreadState::new(SkillsExtensionConfig::from_config(
|
||||
input.config,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,36 +55,12 @@ impl ConfigContributor<Config> for SkillsExtension {
|
||||
_previous_config: &Config,
|
||||
new_config: &Config,
|
||||
) {
|
||||
// TODO(skills-extension): update any cached/listing state that depends
|
||||
// on skill config overrides, bundled skills, or include_instructions.
|
||||
thread_store.insert(SkillsExtensionConfig::from_config(new_config));
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextContributor for SkillsExtension {
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_session_store: &'a ExtensionData,
|
||||
thread_store: &'a ExtensionData,
|
||||
) -> Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let Some(config) = thread_store.get::<SkillsExtensionConfig>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !config.include_instructions {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// TODO(skills-extension): render the available-skills developer
|
||||
// block only if the final model needs thread-level guidance that
|
||||
// does not depend on the per-turn SkillCatalog.
|
||||
//
|
||||
// TODO(skills-extension): avoid using raw PromptFragment strings
|
||||
// for final skills context if the extension API grows typed
|
||||
// contextual fragments. Existing skill blocks are typed so resume
|
||||
// and history filtering can recognize them reliably.
|
||||
Vec::new()
|
||||
})
|
||||
let next_config = SkillsExtensionConfig::from_config(new_config);
|
||||
if let Some(state) = thread_store.get::<SkillsThreadState>() {
|
||||
state.set_config(next_config);
|
||||
} else {
|
||||
thread_store.insert(SkillsThreadState::new(next_config));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,67 +70,118 @@ impl TurnInputContributor for SkillsExtension {
|
||||
&self,
|
||||
input: TurnInputContext,
|
||||
_session_store: &ExtensionData,
|
||||
_thread_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
) -> Vec<Box<dyn ContextualUserFragment + Send>> {
|
||||
let executor_authorities = input
|
||||
.environments
|
||||
.iter()
|
||||
.map(|environment| {
|
||||
SkillAuthority::new(
|
||||
SkillSourceKind::Executor,
|
||||
environment.environment_id.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let config = thread_state.config();
|
||||
let query = SkillListQuery {
|
||||
turn_id: input.turn_id,
|
||||
executor_authorities,
|
||||
turn_id: input.turn_id.clone(),
|
||||
executor_authorities: input
|
||||
.environments
|
||||
.iter()
|
||||
.map(|environment| {
|
||||
SkillAuthority::new(
|
||||
SkillSourceKind::Executor,
|
||||
environment.environment_id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
include_host_skills: true,
|
||||
include_bundled_skills: config.bundled_skills_enabled,
|
||||
include_remote_skills: true,
|
||||
};
|
||||
let catalog = self
|
||||
.providers
|
||||
.list_for_turn(query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let catalog = self.providers.list_for_turn(query).await;
|
||||
for warning in &catalog.warnings {
|
||||
self.emit_warning(&input.turn_id, warning.clone());
|
||||
}
|
||||
|
||||
let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog);
|
||||
let mut fragments: Vec<Box<dyn ContextualUserFragment + Send>> = Vec::new();
|
||||
if config.include_instructions
|
||||
&& let Some(fragment) = available_skills_fragment(&catalog)
|
||||
{
|
||||
fragments.push(Box::new(fragment));
|
||||
}
|
||||
|
||||
let mut warnings = catalog.warnings.clone();
|
||||
let mut main_prompts_injected = false;
|
||||
for entry in &selected_entries {
|
||||
match self.read_main_prompt(entry).await {
|
||||
Ok(read_result) => {
|
||||
let (contents, truncated) =
|
||||
truncate_main_prompt_contents(read_result.contents.as_str());
|
||||
if truncated {
|
||||
let warning = format!(
|
||||
"Skill `{}` exceeded the main prompt context limit and was truncated.",
|
||||
entry.name
|
||||
);
|
||||
self.emit_warning(&input.turn_id, warning.clone());
|
||||
warnings.push(warning);
|
||||
}
|
||||
let injection = SkillInjection {
|
||||
name: entry.name.clone(),
|
||||
path: entry.rendered_path().to_string(),
|
||||
contents,
|
||||
};
|
||||
fragments.push(Box::new(SkillInstructions::from(&injection)));
|
||||
main_prompts_injected = true;
|
||||
}
|
||||
Err(message) => {
|
||||
let warning = format!("Failed to load skill `{}`: {message}", entry.name);
|
||||
self.emit_warning(&input.turn_id, warning.clone());
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
turn_store.insert(SkillsTurnState {
|
||||
catalog,
|
||||
entrypoints_injected: false,
|
||||
selected_entries,
|
||||
warnings,
|
||||
main_prompts_injected,
|
||||
});
|
||||
|
||||
// TODO(skills-extension): after catalog resolution, collect explicit
|
||||
// skill mentions from structured UserInput and text mentions.
|
||||
//
|
||||
// TODO(skills-extension): inject selected entrypoints as typed
|
||||
// contextual user fragments, preserving <skill>...</skill> history
|
||||
// recognition and bounded body size limits.
|
||||
//
|
||||
// TODO(skills-extension): move explicit $skill mention resolution,
|
||||
// SKILL.md reads, skill body injection, and MCP dependency prompting
|
||||
// out of codex-core's turn assembly once provider implementations
|
||||
// are ready to preserve behavior.
|
||||
Vec::new()
|
||||
fragments
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillsExtension {
|
||||
async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result<SkillReadResult, String> {
|
||||
self.providers
|
||||
.read(SkillReadRequest {
|
||||
authority: entry.authority.clone(),
|
||||
package: entry.id.clone(),
|
||||
resource: entry.main_prompt.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.message)
|
||||
}
|
||||
|
||||
fn emit_warning(&self, turn_id: &str, message: String) {
|
||||
self.event_sink.emit(Event {
|
||||
id: turn_id.to_string(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the skills extension contributor sketch.
|
||||
///
|
||||
/// TODO(skills-extension): pass host capabilities here rather than letting the
|
||||
/// extension depend on Session. The final extension needs capability objects for
|
||||
/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP
|
||||
/// dependency install, refreshing MCP servers, and serving app-server catalog
|
||||
/// requests.
|
||||
///
|
||||
/// TODO(skills-extension): plugin handling should stay outside the runtime
|
||||
/// skills model. Plugins are bundle/install units; once installed or refreshed,
|
||||
/// their skill descriptors/roots should be handed to this extension just like
|
||||
/// any other host-owned skill source.
|
||||
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
|
||||
let extension = Arc::new(SkillsExtension::default());
|
||||
install_with_providers(registry, SkillProviders::default());
|
||||
}
|
||||
|
||||
pub fn install_with_providers(
|
||||
registry: &mut ExtensionRegistryBuilder<Config>,
|
||||
providers: SkillProviders,
|
||||
) {
|
||||
let extension = Arc::new(SkillsExtension {
|
||||
providers,
|
||||
event_sink: registry.event_sink(),
|
||||
});
|
||||
registry.thread_lifecycle_contributor(extension.clone());
|
||||
registry.config_contributor(extension.clone());
|
||||
registry.prompt_contributor(extension.clone());
|
||||
registry.turn_input_contributor(extension);
|
||||
}
|
||||
Reference in new issue
Block a user