mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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.
219 lines
6.2 KiB
Rust
219 lines
6.2 KiB
Rust
use std::fmt;
|
|
use std::sync::Arc;
|
|
|
|
use crate::catalog::SkillCatalog;
|
|
use crate::catalog::SkillProviderError;
|
|
use crate::catalog::SkillProviderResult;
|
|
use crate::catalog::SkillReadResult;
|
|
use crate::catalog::SkillSearchResult;
|
|
use crate::catalog::SkillSourceKind;
|
|
use crate::provider::SkillListQuery;
|
|
use crate::provider::SkillProvider;
|
|
use crate::provider::SkillReadRequest;
|
|
use crate::provider::SkillSearchRequest;
|
|
|
|
#[derive(Clone)]
|
|
pub struct SkillProviderSource {
|
|
kind: SkillSourceKind,
|
|
label: String,
|
|
provider: Arc<dyn SkillProvider>,
|
|
}
|
|
|
|
impl SkillProviderSource {
|
|
pub fn new(
|
|
kind: SkillSourceKind,
|
|
label: impl Into<String>,
|
|
provider: Arc<dyn SkillProvider>,
|
|
) -> Self {
|
|
Self {
|
|
kind,
|
|
label: label.into(),
|
|
provider,
|
|
}
|
|
}
|
|
|
|
pub fn host(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
|
|
Self::new(SkillSourceKind::Host, label, provider)
|
|
}
|
|
|
|
pub fn executor(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
|
|
Self::new(SkillSourceKind::Executor, label, provider)
|
|
}
|
|
|
|
pub fn orchestrator(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
|
|
Self::new(SkillSourceKind::Orchestrator, label, provider)
|
|
}
|
|
|
|
fn should_list(&self, query: &SkillListQuery) -> bool {
|
|
match &self.kind {
|
|
SkillSourceKind::Host => query.include_host_skills,
|
|
SkillSourceKind::Executor => !query.executor_roots.is_empty(),
|
|
SkillSourceKind::Orchestrator => query.include_orchestrator_skills,
|
|
SkillSourceKind::Custom(_) => true,
|
|
}
|
|
}
|
|
|
|
fn owns_kind(&self, kind: &SkillSourceKind) -> bool {
|
|
&self.kind == kind
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for SkillProviderSource {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("SkillProviderSource")
|
|
.field("kind", &self.kind)
|
|
.field("label", &self.label)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Debug)]
|
|
pub struct SkillProviders {
|
|
sources: Vec<SkillProviderSource>,
|
|
}
|
|
|
|
impl SkillProviders {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_provider(mut self, source: SkillProviderSource) -> Self {
|
|
self.sources.push(source);
|
|
self
|
|
}
|
|
|
|
pub fn with_host_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
|
|
self.sources
|
|
.push(SkillProviderSource::host("host", provider));
|
|
self
|
|
}
|
|
|
|
pub fn with_executor_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
|
|
self.sources
|
|
.push(SkillProviderSource::executor("executor", provider));
|
|
self
|
|
}
|
|
|
|
pub fn with_orchestrator_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
|
|
self.sources
|
|
.push(SkillProviderSource::orchestrator("orchestrator", provider));
|
|
self
|
|
}
|
|
|
|
pub(crate) fn has_orchestrator_provider(&self) -> bool {
|
|
self.sources
|
|
.iter()
|
|
.any(|source| source.kind == SkillSourceKind::Orchestrator)
|
|
}
|
|
|
|
pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog {
|
|
self.list_matching(&query, |source| source.should_list(&query))
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn list_orchestrator_for_turn(
|
|
&self,
|
|
query: SkillListQuery,
|
|
) -> SkillProviderResult<SkillCatalog> {
|
|
let mut catalog = SkillCatalog::default();
|
|
|
|
for source in self
|
|
.sources
|
|
.iter()
|
|
.filter(|source| source.kind == SkillSourceKind::Orchestrator)
|
|
{
|
|
let source_catalog = source.provider.list(query.clone()).await.map_err(|err| {
|
|
SkillProviderError::new(format!(
|
|
"{} skills unavailable: {}",
|
|
source.label, err.message
|
|
))
|
|
})?;
|
|
catalog.extend(source_catalog);
|
|
}
|
|
|
|
Ok(catalog)
|
|
}
|
|
|
|
async fn list_matching(
|
|
&self,
|
|
query: &SkillListQuery,
|
|
should_list: impl Fn(&SkillProviderSource) -> bool,
|
|
) -> SkillCatalog {
|
|
let mut catalog = SkillCatalog::default();
|
|
|
|
for source in self.sources.iter().filter(|source| should_list(source)) {
|
|
extend_catalog(
|
|
&mut catalog,
|
|
source.provider.list(query.clone()).await,
|
|
source.label.as_str(),
|
|
);
|
|
}
|
|
|
|
catalog
|
|
}
|
|
|
|
pub(crate) async fn read(
|
|
&self,
|
|
request: SkillReadRequest,
|
|
) -> Result<SkillReadResult, SkillProviderError> {
|
|
let mut last_error = None;
|
|
for source in self
|
|
.sources
|
|
.iter()
|
|
.filter(|source| source.owns_kind(&request.authority.kind))
|
|
{
|
|
match source.provider.read(request.clone()).await {
|
|
Ok(result) => return Ok(result),
|
|
Err(err) => last_error = Some(err),
|
|
}
|
|
}
|
|
|
|
match last_error {
|
|
Some(err) => Err(err),
|
|
None => Err(SkillProviderError::new(format!(
|
|
"{} skill provider is not configured",
|
|
request.authority.kind
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn search(
|
|
&self,
|
|
request: SkillSearchRequest,
|
|
) -> Result<SkillSearchResult, SkillProviderError> {
|
|
let mut last_error = None;
|
|
for source in self
|
|
.sources
|
|
.iter()
|
|
.filter(|source| source.owns_kind(&request.authority.kind))
|
|
{
|
|
match source.provider.search(request.clone()).await {
|
|
Ok(result) => return Ok(result),
|
|
Err(err) => last_error = Some(err),
|
|
}
|
|
}
|
|
|
|
match last_error {
|
|
Some(err) => Err(err),
|
|
None => Err(SkillProviderError::new(format!(
|
|
"{} skill provider is not configured",
|
|
request.authority.kind
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extend_catalog(
|
|
catalog: &mut SkillCatalog,
|
|
result: Result<SkillCatalog, SkillProviderError>,
|
|
label: &str,
|
|
) {
|
|
match result {
|
|
Ok(source_catalog) => catalog.extend(source_catalog),
|
|
Err(err) => catalog
|
|
.warnings
|
|
.push(format!("{label} skills unavailable: {}", err.message)),
|
|
}
|
|
}
|