mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
skills: make backend plugin skills invocable without an executor (#27387)
## Why #27198 made the extension-owned `codex_apps` MCP connection the hosted plugin runtime, but its `mcp/skill` resources still bypassed the skills extension. App-server could list and read those resources through generic MCP APIs, but a thread with no selected environment did not expose them in the model's skills catalog or load their `SKILL.md` through `$skill`. Hosted skills should stay remote while using the same typed catalog, source authority, deduplication, bounded contextual catalog, and selected-skill prompt injection as host and executor skills. They should not be downloaded or exposed as ambient filesystem paths. ## What changed - Add a session-scoped `McpResourceClient` over the replaceable MCP connection manager so resource list/read calls follow startup and refresh replacements. - Add a `BackendSkillProvider` that pages `codex_apps` resources, accepts bounded and validated `mcp/skill` entries, and reads a selected skill's `SKILL.md` through the same MCP connection. - Register the remote provider in app-server and include it in the skills catalog even when a thread has no selected capability roots or executor. - Contribute hosted skill metadata through the bounded `AvailableSkillsInstructions` developer-context path, exclude remote entries from per-turn catalog injection, and classify `<skills>` messages as contextual developer content so rollback can trim and rebuild them correctly. ## Testing - Extend the app-server MCP resource integration test with `environments: []` to exercise two-page discovery, filter a non-`mcp/skill` resource, verify the escaped developer catalog entry and user-role `<skill>` fragment containing the fetched `SKILL.md`, and preserve generic MCP resource reads. - Add core event-mapping coverage that classifies `<skills>` developer messages as contextual history.
This commit is contained in:
26 files changed
+877
-116
No files matched your search
@@ -0,0 +1,276 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::mcp::Resource;
|
||||
use codex_protocol::mcp::ResourceContent;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillCatalogEntry;
|
||||
use crate::catalog::SkillPackageId;
|
||||
use crate::catalog::SkillProviderError;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillResourceId;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::catalog::SkillSourceKind;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
use crate::provider::SkillProviderFuture;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::provider::SkillSearchRequest;
|
||||
|
||||
const ORCHESTRATOR_SKILL_MIME_TYPE: &str = "mcp/skill";
|
||||
const ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const ORCHESTRATOR_SKILL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_RESOURCE_PAGES: usize = 10;
|
||||
const MAX_ORCHESTRATOR_SKILLS: usize = 100;
|
||||
const MAX_SKILL_NAME_CHARS: usize = 64;
|
||||
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
|
||||
const MAX_SKILL_URI_CHARS: usize = 1_024;
|
||||
|
||||
/// Discovers and reads skills owned by the orchestrator.
|
||||
///
|
||||
/// The provider uses session-scoped resources without exposing the transport or
|
||||
/// resource server to callers that configure the skills extension.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OrchestratorSkillProvider;
|
||||
|
||||
impl OrchestratorSkillProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillProvider for OrchestratorSkillProvider {
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
Box::pin(async move {
|
||||
let Some(client) = query.mcp_resources else {
|
||||
return Ok(SkillCatalog::default());
|
||||
};
|
||||
if !client.has_server(CODEX_APPS_MCP_SERVER_NAME).await {
|
||||
return Ok(SkillCatalog::default());
|
||||
}
|
||||
|
||||
let discovery_deadline =
|
||||
tokio::time::Instant::now() + ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT;
|
||||
let mut catalog = SkillCatalog::default();
|
||||
let mut cursor = None;
|
||||
let mut seen_cursors = HashSet::new();
|
||||
let mut skill_resources_seen = 0usize;
|
||||
let mut skipped_resources = 0usize;
|
||||
let mut truncated = false;
|
||||
let mut completed_pages = 0usize;
|
||||
|
||||
for _ in 0..MAX_RESOURCE_PAGES {
|
||||
let page = match tokio::time::timeout_at(
|
||||
discovery_deadline,
|
||||
client.list_resources(CODEX_APPS_MCP_SERVER_NAME, cursor.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to list orchestrator skill resources: {err:#}"
|
||||
))
|
||||
}),
|
||||
Err(_) => Err(SkillProviderError::new(format!(
|
||||
"orchestrator skill discovery timed out after {ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT:?}"
|
||||
))),
|
||||
};
|
||||
let result = match page {
|
||||
Ok(result) => result,
|
||||
Err(err) if completed_pages == 0 => return Err(err),
|
||||
Err(err) => {
|
||||
let page_word = if completed_pages == 1 {
|
||||
"page"
|
||||
} else {
|
||||
"pages"
|
||||
};
|
||||
catalog.warnings.push(format!(
|
||||
"Orchestrator skill discovery stopped after {completed_pages} resource {page_word}: {}",
|
||||
err.message
|
||||
));
|
||||
cursor = None;
|
||||
break;
|
||||
}
|
||||
};
|
||||
completed_pages = completed_pages.saturating_add(1);
|
||||
|
||||
for resource in &result.resources {
|
||||
if resource.mime_type.as_deref() != Some(ORCHESTRATOR_SKILL_MIME_TYPE) {
|
||||
continue;
|
||||
}
|
||||
if skill_resources_seen >= MAX_ORCHESTRATOR_SKILLS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
skill_resources_seen = skill_resources_seen.saturating_add(1);
|
||||
match catalog_entry_from_resource(resource) {
|
||||
Some(entry) => catalog.push_entry(entry),
|
||||
None => skipped_resources = skipped_resources.saturating_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
let Some(next_cursor) = result.next_cursor else {
|
||||
cursor = None;
|
||||
break;
|
||||
};
|
||||
if !seen_cursors.insert(next_cursor.clone()) {
|
||||
catalog.warnings.push(
|
||||
"Orchestrator skill resource pagination returned a duplicate cursor."
|
||||
.to_string(),
|
||||
);
|
||||
cursor = None;
|
||||
break;
|
||||
}
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
|
||||
if cursor.is_some() || truncated {
|
||||
catalog.warnings.push(format!(
|
||||
"Orchestrator skill discovery was truncated at {MAX_ORCHESTRATOR_SKILLS} skills or {MAX_RESOURCE_PAGES} resource pages."
|
||||
));
|
||||
}
|
||||
if skipped_resources > 0 {
|
||||
catalog.warnings.push(format!(
|
||||
"Skipped {skipped_resources} malformed orchestrator skill resources."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
|
||||
Box::pin(async move {
|
||||
if request.authority
|
||||
!= SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
|
||||
{
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"orchestrator skill provider cannot read authority {}",
|
||||
request.authority.id
|
||||
)));
|
||||
}
|
||||
let expected_resource = main_prompt_uri(&request.package.0);
|
||||
if request.resource.as_str() != expected_resource {
|
||||
return Err(SkillProviderError::new(
|
||||
"orchestrator skill resource does not match its package",
|
||||
));
|
||||
}
|
||||
|
||||
let Some(client) = request.mcp_resources.as_ref() else {
|
||||
return Err(SkillProviderError::new(
|
||||
"session MCP resource client is not configured",
|
||||
));
|
||||
};
|
||||
let result = tokio::time::timeout(
|
||||
ORCHESTRATOR_SKILL_READ_TIMEOUT,
|
||||
client.read_resource(CODEX_APPS_MCP_SERVER_NAME, request.resource.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
SkillProviderError::new(format!(
|
||||
"orchestrator skill read timed out after {ORCHESTRATOR_SKILL_READ_TIMEOUT:?}"
|
||||
))
|
||||
})?
|
||||
.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read orchestrator skill resource {}: {err:#}",
|
||||
request.resource.as_str()
|
||||
))
|
||||
})?;
|
||||
let contents = result
|
||||
.contents
|
||||
.into_iter()
|
||||
.find_map(|contents| match contents {
|
||||
ResourceContent::Text { uri, text, .. } if uri == request.resource.as_str() => {
|
||||
Some(text)
|
||||
}
|
||||
ResourceContent::Text { .. } | ResourceContent::Blob { .. } => None,
|
||||
});
|
||||
let Some(contents) = contents else {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"orchestrator skill resource {} did not return matching text contents",
|
||||
request.resource.as_str()
|
||||
)));
|
||||
};
|
||||
|
||||
Ok(SkillReadResult {
|
||||
resource: request.resource,
|
||||
contents,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
|
||||
Box::pin(async { Ok(SkillSearchResult::default()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry> {
|
||||
let uri = validated_skill_uri(resource.uri.as_str())?;
|
||||
let meta = resource.meta.as_ref()?.as_object()?;
|
||||
let skill_name = normalized_label(meta.get("skill_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
|
||||
let name = if meta.get("source").and_then(|value| value.as_str()) == Some("user") {
|
||||
skill_name
|
||||
} else {
|
||||
let plugin_name =
|
||||
normalized_label(meta.get("plugin_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
|
||||
let qualified_name = format!("{plugin_name}:{skill_name}");
|
||||
(qualified_name.chars().count() <= MAX_QUALIFIED_SKILL_NAME_CHARS)
|
||||
.then_some(qualified_name)?
|
||||
};
|
||||
let description = normalized_description(resource.description.as_deref().unwrap_or_default())?;
|
||||
let main_prompt = main_prompt_uri(uri);
|
||||
|
||||
Some(
|
||||
SkillCatalogEntry::new(
|
||||
SkillPackageId(uri.to_string()),
|
||||
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME),
|
||||
name,
|
||||
description,
|
||||
SkillResourceId::new(main_prompt),
|
||||
)
|
||||
.with_display_path(uri),
|
||||
)
|
||||
}
|
||||
|
||||
fn validated_skill_uri(uri: &str) -> Option<&str> {
|
||||
let path = uri.strip_prefix("skill://")?;
|
||||
let invalid = path.is_empty()
|
||||
|| uri.chars().count() > MAX_SKILL_URI_CHARS
|
||||
|| uri
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '<' | '>'));
|
||||
(!invalid).then_some(uri)
|
||||
}
|
||||
|
||||
fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
|
||||
let value = normalized_single_line(value, max_chars)?;
|
||||
let invalid = value.is_empty() || value.chars().any(|ch| matches!(ch, '&' | '<' | '>'));
|
||||
(!invalid).then_some(value)
|
||||
}
|
||||
|
||||
fn normalized_description(value: &str) -> Option<String> {
|
||||
normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_single_line(value: &str, max_chars: usize) -> Option<String> {
|
||||
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let valid = value.chars().count() <= max_chars && !value.chars().any(char::is_control);
|
||||
valid.then_some(value)
|
||||
}
|
||||
|
||||
fn main_prompt_uri(package_uri: &str) -> String {
|
||||
format!("{}/SKILL.md", package_uri.trim_end_matches('/'))
|
||||
}
|
||||
Reference in new issue
Block a user