Files
codex/codex-rs/ext/skills/src/selection.rs
T
jifandGitHub 89ac3ec27c Load selected executor skills through extensions (#27184)
## Why

CCA is moving toward a split runtime where the orchestrator may not have
a filesystem, while executors can expose preinstalled plugins and
skills. A thread therefore needs to select capabilities without asking
app-server or core to interpret executor-owned paths through the
orchestrator's filesystem.

The longer-term model is broader than executor skills:

- A plugin is a bundle of skills, MCP servers, connectors/apps, and
hooks.
- A plugin root can be local, executor-owned, or hosted by a backend.
- Components inside one plugin can use different access and execution
mechanisms. A skill may be read from a filesystem or through backend
tools; an HTTP MCP server can run without an executor; a stdio MCP
server or hook needs an execution environment.
- Core should carry generic extension initialization data. The extension
that owns a component should discover it, expose it to the model, and
invoke it through the appropriate runtime.

This PR establishes that architecture through one complete vertical:
selecting a root on an executor, discovering the skills beneath it,
exposing those skills to the model, and reading an explicitly invoked
`SKILL.md` through the same executor.

## Contract

`thread/start` gains an experimental `selectedCapabilityRoots` field:

```json
{
  "selectedCapabilityRoots": [
    {
      "id": "deploy-plugin@1",
      "location": {
        "type": "environment",
        "environmentId": "workspace",
        "path": "/opt/codex/plugins/deploy"
      }
    }
  ]
}
```

The root is intentionally not classified as a "plugin" or "skill" in the
API. It can point at a standalone skill, a directory containing several
skills, or a plugin containing skills and other components. This PR only
teaches the skills extension how to consume it; later extensions can
resolve MCP, connector, and hook components from the same selection.

The platform-supplied `id` is stable selection identity. The location
says which runtime owns the root and gives that runtime an opaque path.
App-server does not inspect or canonicalize the path.

## What changed

### Generic thread extension initialization

App-server converts selected roots into `ExtensionDataInit`. Core
carries that generic initialization value until the final thread ID is
known, then creates thread-scoped `ExtensionData` before lifecycle
contributors run.

This keeps `Session` and core independent of the capability-selection
contract. The initialization value is consumed during construction; it
is not retained as another long-lived `Session` field.

### Executor-backed skills

The skills extension now owns an `ExecutorSkillProvider` that:

- resolves the selected environment through `EnvironmentManager`
- discovers, canonicalizes, and reads skills through that environment's
`ExecutorFileSystem`
- contributes the bounded selected-skill catalog as stable developer
context
- reads an explicitly invoked skill body through the authority that
listed it
- warns when an environment or root is unavailable
- never falls back to the orchestrator filesystem for an executor-owned
root

Skill catalog and instruction fragments have hard byte bounds, which
also bound them below the 10K-token per-item context limit. If a
selected executor skill has the same name as a legacy local skill, the
executor selection owns that invocation and the local body is not
injected a second time.

Existing local and bundled skill loading remains in place. Omitting
`selectedCapabilityRoots` therefore preserves current local-only
behavior.

## Current semantics

- Only environment-owned locations are represented in this first
contract.
- Roots are resolved by the destination extension, not by app-server or
core.
- An unavailable executor or invalid root produces a warning and no
capabilities from that root; it does not trigger a local-filesystem
fallback.
- Selection applies to a newly started active thread.
- MCP servers, connectors, and hooks beneath a selected plugin root are
not activated yet.
- Selection is not yet persisted or inherited across resume, fork, or
subagent creation. Existing local capabilities continue to behave as
they do today in those flows.

## Planned vertical follow-ups

1. **Hosted HTTP MCP:** add an extension-backed HTTP MCP source that
works without an executor, then replace the special-purpose MCP plugins
loader with that implementation.
2. **Executor MCP:** register and execute stdio MCP servers through the
environment that owns the selected plugin root.
3. **Backend skills:** add a hosted skill source whose catalog and
bodies are accessed through extension tools rather than a filesystem.
4. **Connectors and hooks:** activate those components through their
owning extensions, using the same selected-root boundary and
component-specific runtime.
5. **Durable selection:** define the desired-selection lifecycle,
persist it, and make resume, fork, and subagent inheritance explicit
rather than accidental.
6. **Local convergence:** incrementally route existing local plugin,
skill, and MCP loading through the same extension model while preserving
current local behavior.

Each follow-up remains reviewable as an end-to-end capability. The
platform selects roots, generic thread extension data carries the
selection, and the owning extension resolves and operates its component.

## Verification

Coverage added for:

- app-server end-to-end discovery and explicit invocation of a skill
inside an executor-selected plugin root
- exclusive invocation when a selected executor skill collides with a
local skill name
- executor filesystem authority for discovery, canonicalization, and
reads
- thread extension initialization before lifecycle contributors run
- stable executor catalog context, explicit invocation, context
rebuilding, hidden skills, and preserved host/remote catalog behavior

Targeted protocol, core-skills, skills-extension, core lifecycle, and
app-server executor-skill tests were run during development.
2026-06-09 19:51:54 +02:00

130 lines
3.7 KiB
Rust

use std::collections::HashSet;
use codex_core_skills::injection::extract_tool_mentions;
use codex_protocol::user_input::UserInput;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
const SKILL_PATH_PREFIX: &str = "skill://";
pub(crate) fn collect_explicit_skill_mentions(
inputs: &[UserInput],
catalog: &SkillCatalog,
) -> Vec<SkillCatalogEntry> {
let mut selected = Vec::new();
let mut seen = HashSet::new();
let mut blocked_plain_names = HashSet::new();
for input in inputs {
match input {
UserInput::Skill { name, path } => {
blocked_plain_names.insert(name.clone());
select_by_path(catalog, &path.to_string_lossy(), &mut seen, &mut selected);
}
UserInput::Mention { name, path } if path_is_skill(path) => {
blocked_plain_names.insert(name.clone());
select_by_path(catalog, path, &mut seen, &mut selected);
}
UserInput::Text { .. } | UserInput::Image { .. } | UserInput::LocalImage { .. } => {}
UserInput::Mention { .. } => {}
_ => {}
}
}
for input in inputs {
let UserInput::Text { text, .. } = input else {
continue;
};
let mentions = extract_tool_mentions(text);
for path in mentions.paths() {
if path_is_skill(path) {
select_by_path(
catalog,
normalize_skill_path(path),
&mut seen,
&mut selected,
);
}
}
for name in mentions.plain_names() {
if blocked_plain_names.contains(name) {
continue;
}
if let Some(entry) = catalog
.entries
.iter()
.find(|entry| entry.enabled && entry.name == name)
{
push_selected(entry, &mut seen, &mut selected);
}
}
}
selected
}
fn select_by_path(
catalog: &SkillCatalog,
path: &str,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let normalized_path = normalize_skill_path(path);
for entry in catalog.entries.iter().filter(|entry| entry.enabled) {
if entry_matches_path(entry, normalized_path) {
push_selected(entry, seen, selected);
}
}
}
fn push_selected(
entry: &SkillCatalogEntry,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let key = SkillCatalogEntryKey::from(entry);
if seen.insert(key) {
selected.push(entry.clone());
}
}
fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool {
entry.main_prompt.as_str() == path
|| entry.id.0 == path
|| entry
.display_path
.as_deref()
.is_some_and(|display_path| normalize_skill_path(display_path) == path)
}
fn path_is_skill(path: &str) -> bool {
path.starts_with(SKILL_PATH_PREFIX)
|| path
.rsplit(['/', '\\'])
.next()
.is_some_and(|file_name| file_name.eq_ignore_ascii_case("SKILL.md"))
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct SkillCatalogEntryKey {
authority: SkillAuthority,
package: SkillPackageId,
}
impl From<&SkillCatalogEntry> for SkillCatalogEntryKey {
fn from(entry: &SkillCatalogEntry) -> Self {
Self {
authority: entry.authority.clone(),
package: entry.id.clone(),
}
}
}