Files
codex/codex-rs/ext/skills/src/sources.rs
T
jifandGitHub 7b3eb8e4bc skills: expose remote skill resource tools (#27388)
## 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.
2026-06-11 12:38:04 +02:00

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)),
}
}