mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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.
This commit is contained in:
@@ -59,3 +59,4 @@ pub use registry::ExtensionRegistry;
|
||||
pub use registry::ExtensionRegistryBuilder;
|
||||
pub use registry::empty_extension_registry;
|
||||
pub use state::ExtensionData;
|
||||
pub use state::ExtensionDataInit;
|
||||
|
||||
@@ -7,6 +7,32 @@ use std::sync::PoisonError;
|
||||
|
||||
type ErasedData = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Typed values supplied before an [`ExtensionData`] scope is created.
|
||||
///
|
||||
/// Hosts consume this value once to seed a scope before lifecycle contributors
|
||||
/// run. It does not install extensions or provide persistence.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExtensionDataInit {
|
||||
entries: HashMap<TypeId, ErasedData>,
|
||||
}
|
||||
|
||||
impl ExtensionDataInit {
|
||||
/// Creates an empty extension data initializer.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Stores `value` as the initial attachment of type `T`.
|
||||
pub fn insert<T>(&mut self, value: T) -> Option<Arc<T>>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
self.entries
|
||||
.insert(TypeId::of::<T>(), Arc::new(value))
|
||||
.map(downcast_data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed extension-owned data attached to one host object.
|
||||
#[derive(Debug)]
|
||||
pub struct ExtensionData {
|
||||
@@ -17,9 +43,14 @@ pub struct ExtensionData {
|
||||
impl ExtensionData {
|
||||
/// Creates an empty attachment map for one host-owned scope.
|
||||
pub fn new(level_id: impl Into<String>) -> Self {
|
||||
Self::new_with_init(level_id, ExtensionDataInit::default())
|
||||
}
|
||||
|
||||
/// Creates an attachment map seeded with host-supplied initial data.
|
||||
pub fn new_with_init(level_id: impl Into<String>, init: ExtensionDataInit) -> Self {
|
||||
Self {
|
||||
level_id: level_id.into(),
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
entries: Mutex::new(init.entries),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ workspace = true
|
||||
async-trait = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_core_skills::model::SkillDependencies;
|
||||
use codex_exec_server::EnvironmentPathRef;
|
||||
|
||||
/// Source authority that owns a skill package and must be used to read it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -56,9 +57,37 @@ impl SkillAuthority {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SkillPackageId(pub String);
|
||||
|
||||
/// Opaque resource id inside a skill package.
|
||||
/// Opaque resource id inside a skill package, optionally bound to the
|
||||
/// environment path that owns its contents.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SkillResourceId(pub String);
|
||||
pub struct SkillResourceId {
|
||||
id: String,
|
||||
environment_path: Option<EnvironmentPathRef>,
|
||||
}
|
||||
|
||||
impl SkillResourceId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
environment_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn environment(id: impl Into<String>, path: EnvironmentPathRef) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
environment_path: Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub(crate) fn environment_path(&self) -> Option<&EnvironmentPathRef> {
|
||||
self.environment_path.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata shown in the always-visible skills catalog.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -125,7 +154,7 @@ impl SkillCatalogEntry {
|
||||
pub(crate) fn rendered_path(&self) -> &str {
|
||||
self.display_path
|
||||
.as_deref()
|
||||
.unwrap_or(self.main_prompt.0.as_str())
|
||||
.unwrap_or_else(|| self.main_prompt.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,27 +6,32 @@ 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::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::TurnInputContext;
|
||||
use codex_extension_api::TurnInputContributor;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
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;
|
||||
@@ -42,11 +47,15 @@ struct SkillsExtension {
|
||||
#[async_trait::async_trait]
|
||||
impl ThreadLifecycleContributor<Config> for SkillsExtension {
|
||||
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
|
||||
input
|
||||
let selected_roots = input
|
||||
.thread_store
|
||||
.insert(SkillsThreadState::new(SkillsExtensionConfig::from_config(
|
||||
input.config,
|
||||
)));
|
||||
.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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +71,47 @@ impl ConfigContributor<Config> for SkillsExtension {
|
||||
if let Some(state) = thread_store.get::<SkillsThreadState>() {
|
||||
state.set_config(next_config);
|
||||
} else {
|
||||
thread_store.insert(SkillsThreadState::new(next_config));
|
||||
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 || thread_state.selected_roots().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let catalog = self
|
||||
.providers
|
||||
.list_for_turn(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_remote_skills: false,
|
||||
})
|
||||
.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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnInputContributor for SkillsExtension {
|
||||
async fn contribute(
|
||||
@@ -84,16 +129,7 @@ impl TurnInputContributor for SkillsExtension {
|
||||
let host_loaded_skills = turn_store.get::<HostLoadedSkills>();
|
||||
let query = SkillListQuery {
|
||||
turn_id: input.turn_id.clone(),
|
||||
executor_authorities: input
|
||||
.environments
|
||||
.iter()
|
||||
.map(|environment| {
|
||||
SkillAuthority::new(
|
||||
SkillSourceKind::Executor,
|
||||
environment.environment_id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
executor_roots: thread_state.selected_roots().to_vec(),
|
||||
host: host_loaded_skills.clone(),
|
||||
include_host_skills: true,
|
||||
include_bundled_skills: config.bundled_skills_enabled,
|
||||
@@ -106,10 +142,14 @@ impl TurnInputContributor for SkillsExtension {
|
||||
|
||||
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 Some(fragment) = available_skills_fragment(&catalog)
|
||||
{
|
||||
fragments.push(Box::new(fragment));
|
||||
if config.include_instructions {
|
||||
let mut turn_catalog = catalog.clone();
|
||||
turn_catalog
|
||||
.entries
|
||||
.retain(|entry| entry.authority.kind != SkillSourceKind::Executor);
|
||||
if let Some(fragment) = available_skills_fragment(&turn_catalog) {
|
||||
fragments.push(Box::new(fragment));
|
||||
}
|
||||
}
|
||||
|
||||
let mut warnings = catalog.warnings.clone();
|
||||
@@ -132,14 +172,14 @@ impl TurnInputContributor for SkillsExtension {
|
||||
warnings.push(warning);
|
||||
}
|
||||
let injection = SkillInjection {
|
||||
name: entry.name.clone(),
|
||||
path: entry.rendered_path().to_string(),
|
||||
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.0.clone());
|
||||
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
@@ -150,6 +190,23 @@ impl TurnInputContributor for SkillsExtension {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -206,5 +263,6 @@ pub fn install_with_providers(
|
||||
});
|
||||
registry.thread_lifecycle_contributor(extension.clone());
|
||||
registry.config_contributor(extension.clone());
|
||||
registry.prompt_contributor(extension.clone());
|
||||
registry.turn_input_contributor(extension);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ mod state;
|
||||
|
||||
pub use extension::install;
|
||||
pub use extension::install_with_providers;
|
||||
pub use provider::ExecutorSkillProvider;
|
||||
pub use provider::HostSkillProvider;
|
||||
pub use provider::SkillProvider;
|
||||
pub use sources::SkillProviderSource;
|
||||
pub use sources::SkillProviders;
|
||||
|
||||
@@ -2,9 +2,11 @@ use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod executor;
|
||||
mod host;
|
||||
|
||||
use codex_core_skills::HostLoadedSkills;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalog;
|
||||
@@ -14,12 +16,13 @@ use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillResourceId;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
|
||||
pub use executor::ExecutorSkillProvider;
|
||||
pub use host::HostSkillProvider;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkillListQuery {
|
||||
pub turn_id: String,
|
||||
pub executor_authorities: Vec<SkillAuthority>,
|
||||
pub executor_roots: Vec<SelectedCapabilityRoot>,
|
||||
pub host: Option<Arc<HostLoadedSkills>>,
|
||||
pub include_host_skills: bool,
|
||||
pub include_bundled_skills: bool,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_core_skills::filter_skill_load_outcome_for_product;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::EnvironmentPathRef;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
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;
|
||||
|
||||
/// Discovers and reads skills through the filesystem owned by an execution environment.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutorSkillProvider {
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
restriction_product: Option<Product>,
|
||||
}
|
||||
|
||||
impl ExecutorSkillProvider {
|
||||
pub fn new_with_restriction_product(
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Self {
|
||||
Self {
|
||||
environment_manager,
|
||||
restriction_product,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillProvider for ExecutorSkillProvider {
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
Box::pin(async move {
|
||||
let mut catalog = SkillCatalog::default();
|
||||
for selected_root in query.executor_roots {
|
||||
let selected_root_id = selected_root.id;
|
||||
let CapabilityRootLocation::Environment {
|
||||
environment_id,
|
||||
path,
|
||||
} = selected_root.location;
|
||||
let authority =
|
||||
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
|
||||
let Some(environment) = self.environment_manager.get_environment(&environment_id)
|
||||
else {
|
||||
catalog.warnings.push(format!(
|
||||
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let root_path = match executor_absolute_path(&path) {
|
||||
Ok(root_path) => root_path,
|
||||
Err(err) => {
|
||||
catalog.warnings.push(format!(
|
||||
"Selected capability root `{selected_root_id}` has invalid path `{path}`: {err}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_system = environment.get_filesystem();
|
||||
let outcome = filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots([SkillRoot {
|
||||
path: root_path.clone(),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&file_system),
|
||||
plugin_id: None,
|
||||
plugin_root: None,
|
||||
}])
|
||||
.await,
|
||||
self.restriction_product,
|
||||
);
|
||||
catalog.warnings.extend(outcome.errors.iter().map(|err| {
|
||||
format!(
|
||||
"Failed to load executor skill at {}: {}",
|
||||
err.path.display(),
|
||||
err.message
|
||||
)
|
||||
}));
|
||||
for (skill, enabled) in outcome.skills_with_enabled() {
|
||||
catalog.push_entry(catalog_entry_from_skill(
|
||||
skill,
|
||||
enabled,
|
||||
authority.clone(),
|
||||
&selected_root_id,
|
||||
Arc::clone(&file_system),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
|
||||
Box::pin(async move {
|
||||
if request.authority.kind != SkillSourceKind::Executor {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"executor skill provider cannot read {} resources",
|
||||
request.authority.kind
|
||||
)));
|
||||
}
|
||||
if request.package.0 != request.resource.as_str() {
|
||||
return Err(SkillProviderError::new(
|
||||
"executor skill resource does not match its package",
|
||||
));
|
||||
}
|
||||
let Some(resource_path) = request.resource.environment_path() else {
|
||||
return Err(SkillProviderError::new(
|
||||
"executor skill resource is not bound to an environment",
|
||||
));
|
||||
};
|
||||
let contents = resource_path
|
||||
.read_to_string(/*sandbox*/ None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read executor skill resource {}: {err}",
|
||||
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_skill(
|
||||
skill: &SkillMetadata,
|
||||
enabled: bool,
|
||||
authority: SkillAuthority,
|
||||
selected_root_id: &str,
|
||||
file_system: Arc<dyn codex_exec_server::ExecutorFileSystem>,
|
||||
) -> SkillCatalogEntry {
|
||||
let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned();
|
||||
let normalized_path = skill_path.replace('\\', "/");
|
||||
let display_path = format!(
|
||||
"skill://{selected_root_id}/{}",
|
||||
normalized_path.trim_start_matches('/')
|
||||
);
|
||||
let mut entry = SkillCatalogEntry::new(
|
||||
SkillPackageId(display_path.clone()),
|
||||
authority,
|
||||
skill.name.clone(),
|
||||
skill.description.clone(),
|
||||
SkillResourceId::environment(
|
||||
display_path.clone(),
|
||||
EnvironmentPathRef::new(file_system, skill.path_to_skills_md.clone()),
|
||||
),
|
||||
)
|
||||
.with_short_description(skill.short_description.clone())
|
||||
.with_display_path(display_path)
|
||||
.with_dependencies(skill.dependencies.clone());
|
||||
|
||||
if !enabled {
|
||||
entry = entry.disabled();
|
||||
}
|
||||
if !skill.allows_implicit_invocation() {
|
||||
entry = entry.hidden_from_prompt();
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn executor_absolute_path(path: &str) -> std::io::Result<AbsolutePathBuf> {
|
||||
let path = PathBuf::from(path);
|
||||
if !path.is_absolute() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"executor path must be absolute",
|
||||
));
|
||||
}
|
||||
AbsolutePathBuf::from_absolute_path_checked(path)
|
||||
}
|
||||
@@ -55,12 +55,12 @@ impl SkillProvider for HostSkillProvider {
|
||||
};
|
||||
let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| {
|
||||
let skill_path = skill.path_to_skills_md.to_string_lossy();
|
||||
skill_path == request.resource.0.as_str()
|
||||
|| skill_path.replace('\\', "/") == request.resource.0
|
||||
skill_path == request.resource.as_str()
|
||||
|| skill_path.replace('\\', "/") == request.resource.as_str()
|
||||
}) else {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"host skill resource is not loaded: {}",
|
||||
request.resource.0
|
||||
request.resource.as_str()
|
||||
)));
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ impl SkillProvider for HostSkillProvider {
|
||||
.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read host skill resource {}: {err}",
|
||||
request.resource.0
|
||||
request.resource.as_str()
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -117,7 +117,7 @@ fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalo
|
||||
SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID),
|
||||
skill.name.clone(),
|
||||
skill.description.clone(),
|
||||
SkillResourceId(skill_path),
|
||||
SkillResourceId::new(skill_path),
|
||||
)
|
||||
.with_short_description(skill.short_description.clone())
|
||||
.with_display_path(display_path)
|
||||
|
||||
@@ -2,11 +2,14 @@ use codex_core_skills::render_available_skills_body;
|
||||
use codex_extension_api::ContextualUserFragment;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
|
||||
const MAX_AVAILABLE_SKILLS_CHARS: usize = 8_000;
|
||||
const MAX_MAIN_PROMPT_CHARS: usize = 40_000;
|
||||
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
|
||||
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
|
||||
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
|
||||
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AvailableSkillsFragment {
|
||||
@@ -32,7 +35,7 @@ impl ContextualUserFragment for AvailableSkillsFragment {
|
||||
}
|
||||
|
||||
pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<AvailableSkillsFragment> {
|
||||
let mut total_chars = 0usize;
|
||||
let mut total_bytes = 0usize;
|
||||
let mut omitted = 0usize;
|
||||
let mut skill_lines = Vec::new();
|
||||
|
||||
@@ -46,12 +49,12 @@ pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<Availa
|
||||
.as_deref()
|
||||
.unwrap_or(entry.description.as_str());
|
||||
let line = render_skill_line(entry.name.as_str(), description, entry.rendered_path());
|
||||
let next_chars = total_chars.saturating_add(line.chars().count());
|
||||
if next_chars > MAX_AVAILABLE_SKILLS_CHARS {
|
||||
let next_bytes = total_bytes.saturating_add(line.len());
|
||||
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
|
||||
omitted = omitted.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
total_chars = next_chars;
|
||||
total_bytes = next_bytes;
|
||||
skill_lines.push(line);
|
||||
}
|
||||
|
||||
@@ -79,12 +82,10 @@ fn render_skill_line(name: &str, description: &str, path: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) {
|
||||
let mut chars = 0usize;
|
||||
for (index, _) in contents.char_indices() {
|
||||
if chars == MAX_MAIN_PROMPT_CHARS {
|
||||
return (contents[..index].to_string(), true);
|
||||
}
|
||||
chars = chars.saturating_add(1);
|
||||
}
|
||||
(contents.to_string(), false)
|
||||
truncate_utf8_to_bytes(contents, MAX_MAIN_PROMPT_BYTES)
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_utf8_to_bytes(contents: &str, max_bytes: usize) -> (String, bool) {
|
||||
let truncated = take_bytes_at_char_boundary(contents, max_bytes);
|
||||
(truncated.to_string(), truncated.len() < contents.len())
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ fn push_selected(
|
||||
}
|
||||
|
||||
fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool {
|
||||
entry.main_prompt.0 == path
|
||||
entry.main_prompt.as_str() == path
|
||||
|| entry.id.0 == path
|
||||
|| entry
|
||||
.display_path
|
||||
.as_deref()
|
||||
.is_some_and(|display_path| display_path == path)
|
||||
.is_some_and(|display_path| normalize_skill_path(display_path) == path)
|
||||
}
|
||||
|
||||
fn path_is_skill(path: &str) -> bool {
|
||||
|
||||
@@ -46,7 +46,7 @@ impl SkillProviderSource {
|
||||
fn should_list(&self, query: &SkillListQuery) -> bool {
|
||||
match &self.kind {
|
||||
SkillSourceKind::Host => query.include_host_skills,
|
||||
SkillSourceKind::Executor => !query.executor_authorities.is_empty(),
|
||||
SkillSourceKind::Executor => !query.executor_roots.is_empty(),
|
||||
SkillSourceKind::Remote => query.include_remote_skills,
|
||||
SkillSourceKind::Custom(_) => true,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_core::config::Config;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
@@ -22,12 +23,17 @@ impl SkillsExtensionConfig {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SkillsThreadState {
|
||||
config: Mutex<SkillsExtensionConfig>,
|
||||
selected_roots: Vec<SelectedCapabilityRoot>,
|
||||
}
|
||||
|
||||
impl SkillsThreadState {
|
||||
pub(crate) fn new(config: SkillsExtensionConfig) -> Self {
|
||||
pub(crate) fn new(
|
||||
config: SkillsExtensionConfig,
|
||||
selected_roots: Vec<SelectedCapabilityRoot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config: Mutex::new(config),
|
||||
selected_roots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +50,10 @@ impl SkillsThreadState {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = config;
|
||||
}
|
||||
|
||||
pub(crate) fn selected_roots(&self) -> &[SelectedCapabilityRoot] {
|
||||
&self.selected_roots
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_core_skills::HostLoadedSkills;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::CopyOptions;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::FileMetadata;
|
||||
use codex_exec_server::FileSystemResult;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::ReadDirectoryEntry;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_skills_extension::ExecutorSkillProvider;
|
||||
use codex_skills_extension::catalog::SkillReadResult;
|
||||
use codex_skills_extension::provider::SkillListQuery;
|
||||
use codex_skills_extension::provider::SkillProvider;
|
||||
use codex_skills_extension::provider::SkillReadRequest;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const SKILL_CONTENTS: &str =
|
||||
"---\nname: synthetic\ndescription: Synthetic executor skill.\n---\n\nEXECUTOR_ONLY_BODY\n";
|
||||
static NEXT_TEST_ROOT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct SyntheticFileSystem {
|
||||
alias_root: AbsolutePathBuf,
|
||||
canonical_root: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl SyntheticFileSystem {
|
||||
fn metadata(&self, path: &AbsolutePathBuf) -> io::Result<FileMetadata> {
|
||||
let skill_dir = self.canonical_root.join("skill");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
let (is_directory, is_file) = if path == &self.canonical_root || path == &skill_dir {
|
||||
(true, false)
|
||||
} else if path == &skill_path {
|
||||
(false, true)
|
||||
} else {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "not found"));
|
||||
};
|
||||
Ok(FileMetadata {
|
||||
is_directory,
|
||||
is_file,
|
||||
is_symlink: false,
|
||||
created_at_ms: 0,
|
||||
modified_at_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for SyntheticFileSystem {
|
||||
async fn canonicalize(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<AbsolutePathBuf> {
|
||||
if path == &self.alias_root {
|
||||
return Ok(self.canonical_root.clone());
|
||||
}
|
||||
self.metadata(path)?;
|
||||
Ok(path.clone())
|
||||
}
|
||||
|
||||
async fn join(
|
||||
&self,
|
||||
base_path: &AbsolutePathBuf,
|
||||
path: &Path,
|
||||
) -> FileSystemResult<AbsolutePathBuf> {
|
||||
Ok(base_path.join(path))
|
||||
}
|
||||
|
||||
async fn parent(&self, path: &AbsolutePathBuf) -> FileSystemResult<Option<AbsolutePathBuf>> {
|
||||
Ok(path.parent())
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
if path == &self.canonical_root.join("skill/SKILL.md") {
|
||||
Ok(SKILL_CONTENTS.as_bytes().to_vec())
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_contents: Vec<u8>,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_options: CreateDirectoryOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
self.metadata(path)
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
if path == &self.canonical_root {
|
||||
Ok(vec![ReadDirectoryEntry {
|
||||
file_name: "skill".to_string(),
|
||||
is_directory: true,
|
||||
is_file: false,
|
||||
}])
|
||||
} else if path == &self.canonical_root.join("skill") {
|
||||
Ok(vec![ReadDirectoryEntry {
|
||||
file_name: "SKILL.md".to_string(),
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
}])
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_options: RemoveOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
_source_path: &AbsolutePathBuf,
|
||||
_destination_path: &AbsolutePathBuf,
|
||||
_options: CopyOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_loading_and_reads_use_the_supplied_executor_file_system() {
|
||||
let test_root =
|
||||
std::env::temp_dir().join(format!("codex-executor-skill-fs-{}", std::process::id()));
|
||||
let alias_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("alias"))
|
||||
.expect("absolute path");
|
||||
let canonical_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("canonical"))
|
||||
.expect("absolute path");
|
||||
assert!(!alias_root.as_path().exists());
|
||||
assert!(!canonical_root.as_path().exists());
|
||||
|
||||
let outcome = load_skills_from_roots([SkillRoot {
|
||||
path: alias_root.clone(),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::new(SyntheticFileSystem {
|
||||
alias_root,
|
||||
canonical_root: canonical_root.clone(),
|
||||
}),
|
||||
plugin_id: None,
|
||||
plugin_root: None,
|
||||
}])
|
||||
.await;
|
||||
assert_eq!(outcome.errors, Vec::new());
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
|
||||
let skill = outcome.skills[0].clone();
|
||||
assert_eq!(skill.name, "synthetic");
|
||||
assert_eq!(
|
||||
skill.path_to_skills_md,
|
||||
canonical_root.join("skill/SKILL.md")
|
||||
);
|
||||
let loaded = HostLoadedSkills::new(Arc::new(outcome));
|
||||
assert_eq!(
|
||||
loaded.read_skill_text(&skill).await.expect("skill body"),
|
||||
SKILL_CONTENTS
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_provider_reads_from_the_environment_instance_used_for_listing() {
|
||||
let test_root = create_local_skill_root("bound-instance").expect("create local skill root");
|
||||
let root_path = test_root.to_string_lossy().into_owned();
|
||||
let environment_manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||
let provider = ExecutorSkillProvider::new_with_restriction_product(
|
||||
Arc::clone(&environment_manager),
|
||||
/*restriction_product*/ None,
|
||||
);
|
||||
let catalog = provider
|
||||
.list(SkillListQuery {
|
||||
turn_id: "turn-1".to_string(),
|
||||
executor_roots: vec![SelectedCapabilityRoot {
|
||||
id: "root-a".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: root_path,
|
||||
},
|
||||
}],
|
||||
host: None,
|
||||
include_host_skills: false,
|
||||
include_bundled_skills: true,
|
||||
include_remote_skills: false,
|
||||
})
|
||||
.await
|
||||
.expect("list executor skills");
|
||||
let entry = catalog
|
||||
.entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("listed executor skill");
|
||||
let resource = entry.main_prompt.clone();
|
||||
|
||||
environment_manager
|
||||
.upsert_environment("local".to_string(), "http://127.0.0.1:1".to_string())
|
||||
.expect("replace environment");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.read(SkillReadRequest {
|
||||
authority: entry.authority,
|
||||
package: entry.id,
|
||||
resource: resource.clone(),
|
||||
host: None,
|
||||
})
|
||||
.await
|
||||
.expect("read bound executor skill"),
|
||||
SkillReadResult {
|
||||
resource,
|
||||
contents: SKILL_CONTENTS.to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_root_id_distinguishes_identical_executor_paths() {
|
||||
let test_root = create_local_skill_root("root-identity").expect("create local skill root");
|
||||
let root_path = test_root.to_string_lossy().into_owned();
|
||||
let canonical_root = AbsolutePathBuf::from_absolute_path_checked(&test_root)
|
||||
.expect("absolute skill root")
|
||||
.canonicalize()
|
||||
.expect("canonicalize skill root")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let provider = ExecutorSkillProvider::new_with_restriction_product(
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
/*restriction_product*/ None,
|
||||
);
|
||||
|
||||
let catalog = provider
|
||||
.list(SkillListQuery {
|
||||
turn_id: "turn-1".to_string(),
|
||||
executor_roots: ["root-a", "root-b"]
|
||||
.into_iter()
|
||||
.map(|id| SelectedCapabilityRoot {
|
||||
id: id.to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: root_path.clone(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
host: None,
|
||||
include_host_skills: false,
|
||||
include_bundled_skills: true,
|
||||
include_remote_skills: false,
|
||||
})
|
||||
.await
|
||||
.expect("list executor skills");
|
||||
|
||||
assert_eq!(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.authority.id.clone(),
|
||||
entry.display_path.clone().expect("display path"),
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(
|
||||
"root-a".to_string(),
|
||||
format!(
|
||||
"skill://root-a/{}/skill/SKILL.md",
|
||||
canonical_root.trim_start_matches('/')
|
||||
),
|
||||
),
|
||||
(
|
||||
"root-b".to_string(),
|
||||
format!(
|
||||
"skill://root-b/{}/skill/SKILL.md",
|
||||
canonical_root.trim_start_matches('/')
|
||||
),
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||
}
|
||||
|
||||
fn create_local_skill_root(label: &str) -> io::Result<std::path::PathBuf> {
|
||||
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let test_root = std::env::temp_dir().join(format!(
|
||||
"codex-executor-skill-{label}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
let skill_dir = test_root.join("skill");
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
std::fs::write(skill_dir.join("SKILL.md"), SKILL_CONTENTS)?;
|
||||
Ok(test_root)
|
||||
}
|
||||
@@ -14,7 +14,8 @@ use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::TurnInputContext;
|
||||
use codex_extension_api::TurnInputEnvironment;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -112,6 +113,7 @@ async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult
|
||||
.await;
|
||||
|
||||
assert_eq!(2, fragments.len());
|
||||
assert_eq!("developer", fragments[0].role());
|
||||
assert!(fragments[0].render().contains("demo"));
|
||||
assert!(fragments[0].render().contains(&skill_prompt_path));
|
||||
assert_eq!("user", fragments[1].role());
|
||||
@@ -128,42 +130,35 @@ async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult {
|
||||
let host_read_requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let remote_read_requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let host_provider = Arc::new(StaticSkillProvider {
|
||||
async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_input() -> TestResult
|
||||
{
|
||||
let read_requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let executor_provider = Arc::new(StaticSkillProvider {
|
||||
catalog: SkillCatalog {
|
||||
entries: vec![test_entry(
|
||||
SkillSourceKind::Host,
|
||||
"host",
|
||||
"host/lint-fix",
|
||||
SkillSourceKind::Executor,
|
||||
"env-1",
|
||||
"executor/lint-fix",
|
||||
"lint-fix/SKILL.md",
|
||||
)],
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
read_requests: Arc::clone(&host_read_requests),
|
||||
read_requests: Arc::clone(&read_requests),
|
||||
});
|
||||
let remote_provider = Arc::new(StaticSkillProvider {
|
||||
catalog: SkillCatalog {
|
||||
entries: vec![test_entry(
|
||||
SkillSourceKind::Remote,
|
||||
"remote",
|
||||
"remote/lint-fix",
|
||||
"lint-fix/SKILL.md",
|
||||
)],
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
read_requests: Arc::clone(&remote_read_requests),
|
||||
});
|
||||
let providers = SkillProviders::new()
|
||||
.with_host_provider(host_provider)
|
||||
.with_remote_provider(remote_provider);
|
||||
let providers = SkillProviders::new().with_executor_provider(executor_provider);
|
||||
let mut builder = ExtensionRegistryBuilder::new();
|
||||
install_with_providers(&mut builder, providers);
|
||||
let registry = builder.build();
|
||||
|
||||
let session_store = ExtensionData::new("session");
|
||||
let thread_store = ExtensionData::new("thread");
|
||||
thread_store.insert(vec![SelectedCapabilityRoot {
|
||||
id: "lint-fix".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "env-1".to_string(),
|
||||
path: "/skills/lint-fix".to_string(),
|
||||
},
|
||||
}]);
|
||||
let session_source = SessionSource::Cli;
|
||||
let config = default_config().await?;
|
||||
registry.thread_lifecycle_contributors()[0]
|
||||
@@ -176,6 +171,17 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
|
||||
})
|
||||
.await;
|
||||
|
||||
let prompt_fragments = registry.context_contributors()[0]
|
||||
.contribute(&session_store, &thread_store)
|
||||
.await;
|
||||
assert_eq!(1, prompt_fragments.len());
|
||||
assert!(
|
||||
prompt_fragments[0]
|
||||
.text()
|
||||
.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
|
||||
);
|
||||
assert!(prompt_fragments[0].text().contains("lint-fix"));
|
||||
|
||||
let turn_store = ExtensionData::new("turn-1");
|
||||
let fragments = registry.turn_input_contributors()[0]
|
||||
.contribute(
|
||||
@@ -185,11 +191,7 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
|
||||
text: "$lint-fix please".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
environments: vec![TurnInputEnvironment {
|
||||
environment_id: "env-1".to_string(),
|
||||
cwd: std::env::temp_dir(),
|
||||
is_primary: true,
|
||||
}],
|
||||
environments: Vec::new(),
|
||||
},
|
||||
&session_store,
|
||||
&thread_store,
|
||||
@@ -197,31 +199,23 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(2, fragments.len());
|
||||
assert_eq!("developer", fragments[0].role());
|
||||
assert!(
|
||||
fragments[0]
|
||||
.render()
|
||||
.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
|
||||
);
|
||||
assert!(fragments[0].render().contains("lint-fix"));
|
||||
assert_eq!("user", fragments[1].role());
|
||||
assert!(fragments[1].render().contains("<name>lint-fix</name>"));
|
||||
assert!(fragments[1].render().contains("# Lint Fix"));
|
||||
assert_eq!(1, fragments.len());
|
||||
assert_eq!("user", fragments[0].role());
|
||||
assert!(fragments[0].render().contains("<name>lint-fix</name>"));
|
||||
assert!(fragments[0].render().contains("# Lint Fix"));
|
||||
assert_eq!(
|
||||
vec![(
|
||||
SkillAuthority::new(SkillSourceKind::Host, "host"),
|
||||
SkillPackageId("host/lint-fix".to_string()),
|
||||
SkillResourceId("lint-fix/SKILL.md".to_string()),
|
||||
SkillAuthority::new(SkillSourceKind::Executor, "env-1"),
|
||||
SkillPackageId("executor/lint-fix".to_string()),
|
||||
SkillResourceId::new("lint-fix/SKILL.md"),
|
||||
)],
|
||||
read_request_keys(&host_read_requests)
|
||||
);
|
||||
assert!(
|
||||
remote_read_requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.is_empty()
|
||||
read_request_keys(&read_requests)
|
||||
);
|
||||
let rebuilt_prompt_fragments = registry.context_contributors()[0]
|
||||
.contribute(&session_store, &thread_store)
|
||||
.await;
|
||||
assert_eq!(1, rebuilt_prompt_fragments.len());
|
||||
assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix"));
|
||||
|
||||
let next_turn_store = ExtensionData::new("turn-2");
|
||||
let next_fragments = registry.turn_input_contributors()[0]
|
||||
@@ -240,9 +234,91 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(1, next_fragments.len());
|
||||
assert_eq!("developer", next_fragments[0].role());
|
||||
assert!(next_fragments[0].render().contains("lint-fix"));
|
||||
assert!(next_fragments.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> TestResult {
|
||||
let read_requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let root_a_locator = "skill://root-a/shared/lint-fix/SKILL.md";
|
||||
let root_b_locator = "skill://root-b/shared/lint-fix/SKILL.md";
|
||||
let executor_provider = Arc::new(StaticSkillProvider {
|
||||
catalog: SkillCatalog {
|
||||
entries: [("root-a", root_a_locator), ("root-b", root_b_locator)]
|
||||
.into_iter()
|
||||
.map(|(root_id, locator)| {
|
||||
SkillCatalogEntry::new(
|
||||
SkillPackageId(locator.to_string()),
|
||||
SkillAuthority::new(SkillSourceKind::Executor, root_id),
|
||||
"lint-fix",
|
||||
"Fix lint errors.",
|
||||
SkillResourceId::new(locator),
|
||||
)
|
||||
.with_display_path(locator)
|
||||
})
|
||||
.collect(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
read_requests: Arc::clone(&read_requests),
|
||||
});
|
||||
let providers = SkillProviders::new().with_executor_provider(executor_provider);
|
||||
let mut builder = ExtensionRegistryBuilder::new();
|
||||
install_with_providers(&mut builder, providers);
|
||||
let registry = builder.build();
|
||||
let session_store = ExtensionData::new("session");
|
||||
let thread_store = ExtensionData::new("thread");
|
||||
thread_store.insert(
|
||||
[("root-a", "/skills/root-a"), ("root-b", "/skills/root-b")]
|
||||
.into_iter()
|
||||
.map(|(id, path)| SelectedCapabilityRoot {
|
||||
id: id.to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "env-1".to_string(),
|
||||
path: path.to_string(),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let session_source = SessionSource::Cli;
|
||||
let config = default_config().await?;
|
||||
registry.thread_lifecycle_contributors()[0]
|
||||
.on_thread_start(ThreadStartInput {
|
||||
config: &config,
|
||||
session_source: &session_source,
|
||||
persistent_thread_state_available: true,
|
||||
session_store: &session_store,
|
||||
thread_store: &thread_store,
|
||||
})
|
||||
.await;
|
||||
|
||||
let fragments = registry.turn_input_contributors()[0]
|
||||
.contribute(
|
||||
TurnInputContext {
|
||||
turn_id: "turn-1".to_string(),
|
||||
user_input: vec![UserInput::Mention {
|
||||
name: "lint-fix".to_string(),
|
||||
path: root_b_locator.to_string(),
|
||||
}],
|
||||
environments: Vec::new(),
|
||||
},
|
||||
&session_store,
|
||||
&thread_store,
|
||||
&ExtensionData::new("turn-1"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(1, fragments.len());
|
||||
assert!(fragments[0].render().contains(root_b_locator));
|
||||
assert_eq!(
|
||||
vec![(
|
||||
SkillAuthority::new(SkillSourceKind::Executor, "root-b"),
|
||||
SkillPackageId(root_b_locator.to_string()),
|
||||
SkillResourceId::new(root_b_locator),
|
||||
)],
|
||||
read_request_keys(&read_requests)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -306,15 +382,14 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult {
|
||||
.await;
|
||||
|
||||
assert_eq!(2, fragments.len());
|
||||
let catalog_fragment = fragments[0].render();
|
||||
assert!(catalog_fragment.contains("visible-skill"));
|
||||
assert!(!catalog_fragment.contains("hidden-skill"));
|
||||
assert!(fragments[0].render().contains("visible-skill"));
|
||||
assert!(!fragments[0].render().contains("hidden-skill"));
|
||||
assert!(fragments[1].render().contains("<name>hidden-skill</name>"));
|
||||
assert_eq!(
|
||||
vec![(
|
||||
SkillAuthority::new(SkillSourceKind::Host, "host"),
|
||||
SkillPackageId("host/hidden-skill".to_string()),
|
||||
SkillResourceId("hidden-skill/SKILL.md".to_string()),
|
||||
SkillResourceId::new("hidden-skill/SKILL.md"),
|
||||
)],
|
||||
read_request_keys(&read_requests)
|
||||
);
|
||||
@@ -329,13 +404,9 @@ struct StaticSkillProvider {
|
||||
}
|
||||
|
||||
impl SkillProvider for StaticSkillProvider {
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
fn list(&self, _query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
let catalog = self.catalog.clone();
|
||||
Box::pin(async move {
|
||||
assert!(query.include_host_skills);
|
||||
assert!(query.include_bundled_skills);
|
||||
Ok(catalog)
|
||||
})
|
||||
Box::pin(async move { Ok(catalog) })
|
||||
}
|
||||
|
||||
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
|
||||
@@ -369,7 +440,7 @@ fn test_entry(
|
||||
SkillAuthority::new(kind, authority_id),
|
||||
name,
|
||||
"Fix lint errors.",
|
||||
SkillResourceId(main_prompt.to_string()),
|
||||
SkillResourceId::new(main_prompt),
|
||||
)
|
||||
.with_display_path(format!("skill://{package_id}/SKILL.md"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user