mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## Why PR #27387 makes backend plugin skills discoverable and invocable without an executor, but resources referenced by those skills still sit behind the generic MCP resource surface. The model needs a skills-owned API that preserves the provider authority and package boundary instead of treating remote resources like local files. This is stacked on #27387. ## What - Adds one `skills` namespace with bounded `list` and `read` tools for remote skill providers. - Revalidates `authority + package` against the live remote catalog on every read, then routes the opaque resource ID back through that provider. - Allows the backend provider to read canonical child `skill://` resources while rejecting cross-package, non-canonical, query, fragment, and traversal-shaped URIs. - Caps each serialized tool result at 8 KB. Lists are paginated; reads return an opaque continuation cursor. - Marks the JSON output as external context so memory generation can apply its normal suppression policy. - Deliberately does not add `skills.search`; that waits for a bounded plugin-service search contract. ## Tool contract Pseudo-Python matching the wire shape: ```python from typing import Literal, NotRequired, TypedDict class RemoteSkillAuthority(TypedDict): kind: Literal["remote"] id: str # e.g. "codex_apps" class RemoteSkill(TypedDict): authority: RemoteSkillAuthority package: str # opaque provider-owned package ID name: str description: str main_resource: str # opaque provider-owned SKILL.md ID class SkillsListParams(TypedDict): cursor: NotRequired[str] class SkillsListResult(TypedDict): skills: list[RemoteSkill] next_cursor: str | None warnings: list[str] truncated: bool class SkillsReadParams(TypedDict): authority: RemoteSkillAuthority # copied from skills.list package: str # copied from skills.list resource: str # provider-owned child resource ID cursor: NotRequired[str] # copy next_cursor to continue class SkillsReadResult(TypedDict): resource: str contents: str next_cursor: str | None truncated: bool class Skills: def list(self, params: SkillsListParams) -> SkillsListResult: ... def read(self, params: SkillsReadParams) -> SkillsReadResult: ... ``` There is one namespace for all remote skills, not one tool or MCP server per skill. No resource ID is converted into a filesystem path. ## Backend dependency `/ps/mcp` must support direct reads of child resources such as `skill://plugin_demo/deploy/references/deploy.md`. This PR implements and tests the Codex side of that contract; production child reads remain dependent on the corresponding plugin-service support. Search remains out of scope until that service exposes a bounded search/resource API. ## Validation - Added an app-server integration test covering `skills.list` followed by `skills.read` with no executor. - Ran `just fmt`. - Ran `just bazel-lock-update` and `just bazel-lock-check`. - Did not run Rust tests or Clippy locally, per request; CI will run them.
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
use std::sync::Arc;
|
|
|
|
use codex_core::config::Config;
|
|
use codex_core_skills::HostLoadedSkills;
|
|
use codex_core_skills::SkillInstructions;
|
|
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
|
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::ExtensionFuture;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::PromptFragment;
|
|
use codex_extension_api::ThreadLifecycleContributor;
|
|
use codex_extension_api::ThreadStartInput;
|
|
use codex_extension_api::ToolCall;
|
|
use codex_extension_api::ToolContributor;
|
|
use codex_extension_api::ToolExecutor;
|
|
use codex_extension_api::TurnInputContext;
|
|
use codex_extension_api::TurnInputContributor;
|
|
use codex_mcp::McpResourceClient;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::WarningEvent;
|
|
|
|
use crate::catalog::SkillCatalog;
|
|
use crate::catalog::SkillCatalogEntry;
|
|
use crate::catalog::SkillReadResult;
|
|
use crate::catalog::SkillSourceKind;
|
|
use crate::provider::HostSkillProvider;
|
|
use crate::provider::SkillListQuery;
|
|
use crate::provider::SkillReadRequest;
|
|
use crate::render::MAX_SKILL_NAME_BYTES;
|
|
use crate::render::MAX_SKILL_PATH_BYTES;
|
|
use crate::render::available_skills_fragment;
|
|
use crate::render::truncate_main_prompt_contents;
|
|
use crate::render::truncate_utf8_to_bytes;
|
|
use crate::selection::collect_explicit_skill_mentions;
|
|
use crate::sources::SkillProviders;
|
|
use crate::state::SkillsExtensionConfig;
|
|
use crate::state::SkillsThreadState;
|
|
use crate::state::SkillsTurnState;
|
|
use crate::tools::skill_tools;
|
|
|
|
#[derive(Clone)]
|
|
struct SkillsExtension {
|
|
providers: SkillProviders,
|
|
event_sink: Arc<dyn ExtensionEventSink>,
|
|
}
|
|
|
|
impl ThreadLifecycleContributor<Config> for SkillsExtension {
|
|
fn on_thread_start<'a>(
|
|
&'a self,
|
|
input: ThreadStartInput<'a, Config>,
|
|
) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let selected_roots = input
|
|
.thread_store
|
|
.get::<Vec<SelectedCapabilityRoot>>()
|
|
.map(|selected_roots| selected_roots.as_ref().clone())
|
|
.unwrap_or_default();
|
|
input.thread_store.insert(SkillsThreadState::new(
|
|
SkillsExtensionConfig::from_config(input.config),
|
|
selected_roots,
|
|
));
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ConfigContributor<Config> for SkillsExtension {
|
|
fn on_config_changed(
|
|
&self,
|
|
_session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
_previous_config: &Config,
|
|
new_config: &Config,
|
|
) {
|
|
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, Vec::new()));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ContextContributor for SkillsExtension {
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
session_store: &'a ExtensionData,
|
|
thread_store: &'a ExtensionData,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<PromptFragment>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
|
|
return Vec::new();
|
|
};
|
|
let config = thread_state.config();
|
|
if !config.include_instructions {
|
|
return Vec::new();
|
|
}
|
|
let catalog = self
|
|
.list_skills(
|
|
SkillListQuery {
|
|
turn_id: thread_store.level_id().to_string(),
|
|
executor_roots: thread_state.selected_roots().to_vec(),
|
|
host: None,
|
|
include_host_skills: false,
|
|
include_bundled_skills: config.bundled_skills_enabled,
|
|
include_orchestrator_skills: true,
|
|
mcp_resources: session_store.get::<McpResourceClient>(),
|
|
},
|
|
&thread_state,
|
|
)
|
|
.await;
|
|
for warning in &catalog.warnings {
|
|
self.emit_warning(thread_store.level_id(), warning.clone());
|
|
}
|
|
available_skills_fragment(&catalog)
|
|
.map(|fragment| PromptFragment::developer_capability(fragment.render()))
|
|
.into_iter()
|
|
.collect()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ToolContributor for SkillsExtension {
|
|
fn tools(
|
|
&self,
|
|
session_store: &ExtensionData,
|
|
_thread_store: &ExtensionData,
|
|
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
|
|
if !self.providers.has_orchestrator_provider() {
|
|
return Vec::new();
|
|
}
|
|
|
|
skill_tools(
|
|
self.providers.clone(),
|
|
session_store.get::<McpResourceClient>(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl TurnInputContributor for SkillsExtension {
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
input: TurnInputContext,
|
|
session_store: &'a ExtensionData,
|
|
thread_store: &'a ExtensionData,
|
|
turn_store: &'a ExtensionData,
|
|
) -> ExtensionFuture<'a, Vec<Box<dyn ContextualUserFragment + Send>>> {
|
|
Box::pin(async move {
|
|
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let config = thread_state.config();
|
|
let host_loaded_skills = turn_store.get::<HostLoadedSkills>();
|
|
let query = SkillListQuery {
|
|
turn_id: input.turn_id.clone(),
|
|
executor_roots: thread_state.selected_roots().to_vec(),
|
|
host: host_loaded_skills.clone(),
|
|
include_host_skills: true,
|
|
include_bundled_skills: config.bundled_skills_enabled,
|
|
include_orchestrator_skills: true,
|
|
mcp_resources: session_store.get::<McpResourceClient>(),
|
|
};
|
|
let catalog = self.list_skills(query, &thread_state).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 mut turn_catalog = catalog.clone();
|
|
turn_catalog.entries.retain(|entry| {
|
|
entry.authority.kind != SkillSourceKind::Executor
|
|
&& entry.authority.kind != SkillSourceKind::Orchestrator
|
|
});
|
|
if let Some(fragment) = available_skills_fragment(&turn_catalog) {
|
|
fragments.push(Box::new(fragment));
|
|
}
|
|
}
|
|
|
|
let mut warnings = catalog.warnings.clone();
|
|
let mut main_prompts_injected = false;
|
|
let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default();
|
|
for entry in &selected_entries {
|
|
match self
|
|
.read_main_prompt(entry, host_loaded_skills.clone(), session_store)
|
|
.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: truncate_utf8_to_bytes(&entry.name, MAX_SKILL_NAME_BYTES).0,
|
|
path: truncate_utf8_to_bytes(
|
|
entry.rendered_path(),
|
|
MAX_SKILL_PATH_BYTES,
|
|
)
|
|
.0,
|
|
contents,
|
|
};
|
|
fragments.push(Box::new(SkillInstructions::from(&injection)));
|
|
main_prompts_injected = true;
|
|
if entry.authority.kind == SkillSourceKind::Host {
|
|
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
|
|
}
|
|
}
|
|
Err(message) => {
|
|
let warning = format!("Failed to load skill `{}`: {message}", entry.name);
|
|
self.emit_warning(&input.turn_id, warning.clone());
|
|
warnings.push(warning);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(host_loaded_skills) = &host_loaded_skills {
|
|
for entry in selected_entries
|
|
.iter()
|
|
.filter(|entry| entry.authority.kind != SkillSourceKind::Host)
|
|
{
|
|
for host_skill in host_loaded_skills
|
|
.outcome()
|
|
.skills
|
|
.iter()
|
|
.filter(|host_skill| host_skill.name == entry.name)
|
|
{
|
|
injected_host_skill_prompts
|
|
.insert_path(host_skill.path_to_skills_md.to_string_lossy());
|
|
}
|
|
}
|
|
}
|
|
|
|
turn_store.insert(SkillsTurnState {
|
|
catalog,
|
|
selected_entries,
|
|
warnings,
|
|
main_prompts_injected,
|
|
});
|
|
if !injected_host_skill_prompts.is_empty() {
|
|
turn_store.insert(injected_host_skill_prompts);
|
|
}
|
|
|
|
fragments
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SkillsExtension {
|
|
async fn list_skills(
|
|
&self,
|
|
mut query: SkillListQuery,
|
|
thread_state: &SkillsThreadState,
|
|
) -> SkillCatalog {
|
|
let include_orchestrator_skills = query.include_orchestrator_skills;
|
|
let orchestrator_query = query.clone();
|
|
query.include_orchestrator_skills = false;
|
|
|
|
let mut catalog = self.providers.list_for_turn(query).await;
|
|
if include_orchestrator_skills {
|
|
let orchestrator_catalog = thread_state
|
|
.orchestrator_catalog_snapshot(
|
|
self.providers
|
|
.list_orchestrator_for_turn(orchestrator_query),
|
|
)
|
|
.await;
|
|
catalog.extend(orchestrator_catalog);
|
|
}
|
|
catalog
|
|
}
|
|
|
|
async fn read_main_prompt(
|
|
&self,
|
|
entry: &SkillCatalogEntry,
|
|
host_loaded_skills: Option<Arc<HostLoadedSkills>>,
|
|
session_store: &ExtensionData,
|
|
) -> Result<SkillReadResult, String> {
|
|
self.providers
|
|
.read(SkillReadRequest {
|
|
authority: entry.authority.clone(),
|
|
package: entry.id.clone(),
|
|
resource: entry.main_prompt.clone(),
|
|
host: host_loaded_skills,
|
|
mcp_resources: session_store.get::<McpResourceClient>(),
|
|
})
|
|
.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 }),
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
|
|
install_with_providers(
|
|
registry,
|
|
SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())),
|
|
);
|
|
}
|
|
|
|
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.clone());
|
|
registry.tool_contributor(extension);
|
|
}
|