diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 53ffcddd07..3539bf28c0 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -7,6 +7,7 @@ pub mod model; pub mod remote; pub mod render; mod root_loader; +pub mod runtime; pub mod service; mod skill_instructions; pub mod system; diff --git a/codex-rs/core-skills/src/runtime.rs b/codex-rs/core-skills/src/runtime.rs new file mode 100644 index 0000000000..bad8460fdd --- /dev/null +++ b/codex-rs/core-skills/src/runtime.rs @@ -0,0 +1,371 @@ +use std::any::Any; +use std::fmt; +use std::future::Future; +use std::hash::Hash; +use std::hash::Hasher; +use std::pin::Pin; +use std::sync::Arc; + +use codex_utils_path_uri::PathUri; + +use crate::model::SkillDependencies; + +/// Source authority that owns a skill package and must be used to read it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SkillSourceKind { + /// Codex-hosted skills, including bundled, user, repo, plugin-installed, + /// and downloaded/materialized remote skills. + Host, + /// Skills owned by an execution environment. + Executor, + /// Skills owned by the orchestrator rather than an execution environment. + Orchestrator, + /// A source kind for providers that do not fit an existing transport category. + Custom(String), +} + +impl SkillSourceKind { + pub fn custom(kind: impl Into) -> Self { + Self::Custom(kind.into()) + } + + fn as_str(&self) -> &str { + match self { + Self::Host => "host", + Self::Executor => "executor", + Self::Orchestrator => "orchestrator", + Self::Custom(kind) => kind, + } + } +} + +impl fmt::Display for SkillSourceKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(formatter) + } +} + +/// Opaque authority identity for list/read routing. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillAuthority { + pub kind: SkillSourceKind, + pub id: String, +} + +impl SkillAuthority { + pub fn new(kind: SkillSourceKind, id: impl Into) -> Self { + Self { + kind, + id: id.into(), + } + } +} + +/// Opaque package id. Callers should not parse local paths out of this value. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillPackageId(pub String); + +/// 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 { + id: String, + environment_path: Option, +} + +impl SkillResourceId { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + environment_path: None, + } + } + + pub fn environment( + id: impl Into, + environment_id: impl Into, + path: PathUri, + ) -> Self { + Self { + id: id.into(), + environment_path: Some(EnvironmentSkillResource { + environment_id: environment_id.into(), + path, + }), + } + } + + pub fn as_str(&self) -> &str { + &self.id + } + + pub fn environment_path(&self) -> Option<(&str, &PathUri)> { + self.environment_path + .as_ref() + .map(|resource| (resource.environment_id.as_str(), &resource.path)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct EnvironmentSkillResource { + environment_id: String, + path: PathUri, +} + +/// Metadata shown in the always-visible skills catalog. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillCatalogEntry { + pub id: SkillPackageId, + pub authority: SkillAuthority, + pub name: String, + pub description: String, + pub short_description: Option, + pub main_prompt: SkillResourceId, + pub display_path: Option, + pub dependencies: Option, + pub enabled: bool, + pub prompt_visible: bool, +} + +impl SkillCatalogEntry { + pub fn new( + id: SkillPackageId, + authority: SkillAuthority, + name: impl Into, + description: impl Into, + main_prompt: SkillResourceId, + ) -> Self { + Self { + id, + authority, + name: name.into(), + description: description.into(), + short_description: None, + main_prompt, + display_path: None, + dependencies: None, + enabled: true, + prompt_visible: true, + } + } + + pub fn with_short_description(mut self, short_description: Option) -> Self { + self.short_description = short_description; + self + } + + pub fn with_display_path(mut self, display_path: impl Into) -> Self { + self.display_path = Some(display_path.into()); + self + } + + pub fn with_dependencies(mut self, dependencies: Option) -> Self { + self.dependencies = dependencies; + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub fn hidden_from_prompt(mut self) -> Self { + self.prompt_visible = false; + self + } + + pub fn rendered_path(&self) -> &str { + self.display_path + .as_deref() + .unwrap_or_else(|| self.main_prompt.as_str()) + } +} + +/// Merged catalog for one model step. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillCatalog { + pub entries: Vec, + pub warnings: Vec, +} + +impl SkillCatalog { + pub fn extend(&mut self, other: SkillCatalog) { + for entry in other.entries { + self.push_entry(entry); + } + self.warnings.extend(other.warnings); + } + + pub fn push_entry(&mut self, entry: SkillCatalogEntry) { + if self + .entries + .iter() + .any(|existing| existing.authority == entry.authority && existing.id == entry.id) + { + return; + } + + self.entries.push(entry); + } +} + +/// A request to read one resource from the source that listed it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadRequest { + pub authority: SkillAuthority, + pub package: SkillPackageId, + pub resource: SkillResourceId, +} + +/// Contents returned after resolving a skill resource through its owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadResult { + pub resource: SkillResourceId, + pub contents: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSourceError { + pub message: String, +} + +impl SkillSourceError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for SkillSourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.message.fmt(formatter) + } +} + +impl std::error::Error for SkillSourceError {} + +pub type SkillSourceResult = Result; + +/// Boxed future used to keep heterogeneous runtime skill sources object-safe. +pub type SkillSourceFuture<'a, T> = Pin> + Send + 'a>>; + +/// Opaque identity of the runtime owner captured by a skill source. +#[derive(Clone)] +pub struct SkillSourceIdentity(Arc); + +impl SkillSourceIdentity { + pub fn from_owner(owner: Arc) -> Self + where + T: Any + Send + Sync, + { + Self(owner) + } +} + +impl fmt::Debug for SkillSourceIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SkillSourceIdentity").finish() + } +} + +impl PartialEq for SkillSourceIdentity { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for SkillSourceIdentity {} + +impl Hash for SkillSourceIdentity { + fn hash(&self, state: &mut H) { + (Arc::as_ptr(&self.0) as *const ()).hash(state); + } +} + +/// One bound source of runtime skills. +/// +/// Implementations capture the authority needed to list and read their skills, +/// such as a host snapshot, one executor filesystem, or an orchestrator client. +/// A resource returned by [`SkillSource::list`] must be read through the same +/// source instead of being converted into an ambient local path. +pub trait SkillSource: Send + Sync { + /// Returns the authority category owned by this source. + fn kind(&self) -> SkillSourceKind; + + /// Returns the stable identity of the runtime owner captured by this source. + fn identity(&self) -> SkillSourceIdentity; + + /// Lists the skills available from the authority captured by this source. + fn list(&self) -> SkillSourceFuture<'_, SkillCatalog>; + + /// Reads a resource previously listed by this source. + fn read(&self, request: SkillReadRequest) -> SkillSourceFuture<'_, SkillReadResult>; +} + +#[derive(Clone)] +struct RegisteredSkillSource { + label: String, + source: Arc, +} + +/// Bound skill sources used to build and read one runtime catalog. +#[derive(Clone, Default)] +pub struct SkillSources { + sources: Vec, +} + +impl SkillSources { + pub fn new() -> Self { + Self::default() + } + + pub fn with_source(mut self, label: impl Into, source: Arc) -> Self { + self.sources.push(RegisteredSkillSource { + label: label.into(), + source, + }); + self + } + + pub async fn list(&self) -> SkillCatalog { + let mut catalog = SkillCatalog::default(); + for source in &self.sources { + match source.source.list().await { + Ok(source_catalog) => catalog.extend(source_catalog), + Err(err) => catalog.warnings.push(format!( + "{} skills unavailable: {}", + source.label, err.message + )), + } + } + catalog + } + + pub async fn list_kind(&self, kind: &SkillSourceKind) -> SkillSourceResult { + let mut catalog = SkillCatalog::default(); + for source in self + .sources + .iter() + .filter(|source| source.source.kind() == *kind) + { + let source_catalog = source.source.list().await.map_err(|err| { + SkillSourceError::new(format!( + "{} skills unavailable: {}", + source.label, err.message + )) + })?; + catalog.extend(source_catalog); + } + Ok(catalog) + } +} + +impl fmt::Debug for SkillSources { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_list() + .entries(self.sources.iter().map(|source| &source.label)) + .finish() + } +} diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs index 59977a47ca..74ee02de2a 100644 --- a/codex-rs/ext/skills/src/catalog.rs +++ b/codex-rs/ext/skills/src/catalog.rs @@ -1,212 +1,12 @@ -use codex_core_skills::model::SkillDependencies; -use codex_utils_path_uri::PathUri; - -/// Source authority that owns a skill package and must be used to read it. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum SkillSourceKind { - /// Codex-hosted skills, including bundled, user, repo, plugin-installed, - /// and downloaded/materialized remote skills. - Host, - /// Skills owned by an execution environment. - Executor, - /// Skills owned by the orchestrator rather than an execution environment. - Orchestrator, - /// Extension-private source kind for future providers that do not fit an - /// existing transport category. - Custom(String), -} - -impl SkillSourceKind { - pub fn custom(kind: impl Into) -> Self { - Self::Custom(kind.into()) - } - - fn as_str(&self) -> &str { - match self { - Self::Host => "host", - Self::Executor => "executor", - Self::Orchestrator => "orchestrator", - Self::Custom(kind) => kind, - } - } -} - -impl std::fmt::Display for SkillSourceKind { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.as_str().fmt(formatter) - } -} - -/// Opaque authority identity for list/read routing. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct SkillAuthority { - pub kind: SkillSourceKind, - pub id: String, -} - -impl SkillAuthority { - pub fn new(kind: SkillSourceKind, id: impl Into) -> Self { - Self { - kind, - id: id.into(), - } - } -} - -/// Opaque package id. Callers should not parse local paths out of this value. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct SkillPackageId(pub String); - -/// 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 { - id: String, - environment_path: Option, -} - -impl SkillResourceId { - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - environment_path: None, - } - } - - pub fn environment( - id: impl Into, - environment_id: impl Into, - path: PathUri, - ) -> Self { - Self { - id: id.into(), - environment_path: Some(EnvironmentSkillResource { - environment_id: environment_id.into(), - path, - }), - } - } - - pub fn as_str(&self) -> &str { - &self.id - } - - pub(crate) fn environment_path(&self) -> Option<(&str, &PathUri)> { - self.environment_path - .as_ref() - .map(|resource| (resource.environment_id.as_str(), &resource.path)) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct EnvironmentSkillResource { - environment_id: String, - path: PathUri, -} - -/// Metadata shown in the always-visible skills catalog. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SkillCatalogEntry { - pub id: SkillPackageId, - pub authority: SkillAuthority, - pub name: String, - pub description: String, - pub short_description: Option, - pub main_prompt: SkillResourceId, - pub display_path: Option, - pub dependencies: Option, - pub enabled: bool, - pub prompt_visible: bool, -} - -impl SkillCatalogEntry { - pub fn new( - id: SkillPackageId, - authority: SkillAuthority, - name: impl Into, - description: impl Into, - main_prompt: SkillResourceId, - ) -> Self { - Self { - id, - authority, - name: name.into(), - description: description.into(), - short_description: None, - main_prompt, - display_path: None, - dependencies: None, - enabled: true, - prompt_visible: true, - } - } - - pub fn with_short_description(mut self, short_description: Option) -> Self { - self.short_description = short_description; - self - } - - pub fn with_display_path(mut self, display_path: impl Into) -> Self { - self.display_path = Some(display_path.into()); - self - } - - pub fn with_dependencies(mut self, dependencies: Option) -> Self { - self.dependencies = dependencies; - self - } - - pub fn disabled(mut self) -> Self { - self.enabled = false; - self - } - - pub fn hidden_from_prompt(mut self) -> Self { - self.prompt_visible = false; - self - } - - pub(crate) fn rendered_path(&self) -> &str { - self.display_path - .as_deref() - .unwrap_or_else(|| self.main_prompt.as_str()) - } -} - -/// Merged catalog for one turn. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct SkillCatalog { - pub entries: Vec, - pub warnings: Vec, -} - -impl SkillCatalog { - pub fn extend(&mut self, other: SkillCatalog) { - for entry in other.entries { - self.push_entry(entry); - } - self.warnings.extend(other.warnings); - } - - pub fn push_entry(&mut self, entry: SkillCatalogEntry) { - if self - .entries - .iter() - .any(|existing| existing.authority == entry.authority && existing.id == entry.id) - { - return; - } - - self.entries.push(entry); - } -} - -/// Contents returned after resolving a skill resource through its owner. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SkillReadResult { - pub resource: SkillResourceId, - pub contents: String, -} +pub use codex_core_skills::runtime::SkillAuthority; +pub use codex_core_skills::runtime::SkillCatalog; +pub use codex_core_skills::runtime::SkillCatalogEntry; +pub use codex_core_skills::runtime::SkillPackageId; +pub use codex_core_skills::runtime::SkillReadResult; +pub use codex_core_skills::runtime::SkillResourceId; +pub use codex_core_skills::runtime::SkillSourceError as SkillProviderError; +pub use codex_core_skills::runtime::SkillSourceKind; +pub use codex_core_skills::runtime::SkillSourceResult as SkillProviderResult; /// Search results for a package whose files are not readable through ordinary /// executor filesystem access. @@ -221,26 +21,3 @@ pub struct SkillSearchMatch { pub title: String, pub snippet: String, } - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SkillProviderError { - pub message: String, -} - -impl SkillProviderError { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } -} - -impl std::fmt::Display for SkillProviderError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.message.fmt(formatter) - } -} - -impl std::error::Error for SkillProviderError {} - -pub type SkillProviderResult = Result; diff --git a/codex-rs/ext/skills/src/sources.rs b/codex-rs/ext/skills/src/sources.rs index d558c13b3b..387b9f6566 100644 --- a/codex-rs/ext/skills/src/sources.rs +++ b/codex-rs/ext/skills/src/sources.rs @@ -1,6 +1,13 @@ use std::fmt; use std::sync::Arc; +use codex_core_skills::runtime::SkillReadRequest as RuntimeSkillReadRequest; +use codex_core_skills::runtime::SkillSource; +use codex_core_skills::runtime::SkillSourceFuture; +use codex_core_skills::runtime::SkillSourceIdentity; +use codex_core_skills::runtime::SkillSources; +use codex_mcp::McpResourceClient; + use crate::catalog::SkillCatalog; use crate::catalog::SkillProviderError; use crate::catalog::SkillProviderResult; @@ -17,6 +24,7 @@ pub struct SkillProviderSource { kind: SkillSourceKind, label: String, provider: Arc, + identity: SkillSourceIdentity, } impl SkillProviderSource { @@ -25,10 +33,12 @@ impl SkillProviderSource { label: impl Into, provider: Arc, ) -> Self { + let identity = SkillSourceIdentity::from_owner(Arc::new(Arc::clone(&provider))); Self { kind, label: label.into(), provider, + identity, } } @@ -56,6 +66,17 @@ impl SkillProviderSource { fn owns_kind(&self, kind: &SkillSourceKind) -> bool { &self.kind == kind } + + fn bind(&self, query: SkillListQuery) -> Arc { + Arc::new(BoundSkillProvider { + kind: self.kind.clone(), + provider: Arc::clone(&self.provider), + identity: self.identity.clone(), + host_snapshot: query.host_snapshot.clone(), + mcp_resources: query.mcp_resources.clone(), + query: Some(query), + }) + } } impl fmt::Debug for SkillProviderSource { @@ -108,74 +129,48 @@ impl SkillProviders { } pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog { - self.list_matching(&query, |source| source.should_list(&query)) - .await + self.sources_for_turn(query).list().await } pub(crate) async fn list_orchestrator_for_turn( &self, query: SkillListQuery, ) -> SkillProviderResult { - 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) + self.sources_for_turn(query) + .list_kind(&SkillSourceKind::Orchestrator) + .await } - 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 + fn sources_for_turn(&self, query: SkillListQuery) -> SkillSources { + self.sources + .iter() + .filter(|source| source.should_list(&query)) + .fold(SkillSources::new(), |sources, source| { + sources.with_source(source.label.clone(), source.bind(query.clone())) + }) } pub(crate) async fn read( &self, request: SkillReadRequest, ) -> Result { - let mut last_error = None; - for source in self + let mut sources = 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!( + .filter(|source| source.owns_kind(&request.authority.kind)); + let Some(source) = sources.next() else { + return Err(SkillProviderError::new(format!( "{} skill provider is not configured", request.authority.kind - ))), + ))); + }; + if sources.next().is_some() { + return Err(SkillProviderError::new(format!( + "{} skill authority is ambiguous", + request.authority.kind + ))); } + source.provider.read(request).await } pub async fn search( @@ -204,15 +199,42 @@ impl SkillProviders { } } -fn extend_catalog( - catalog: &mut SkillCatalog, - result: Result, - label: &str, -) { - match result { - Ok(source_catalog) => catalog.extend(source_catalog), - Err(err) => catalog - .warnings - .push(format!("{label} skills unavailable: {}", err.message)), +struct BoundSkillProvider { + kind: SkillSourceKind, + provider: Arc, + identity: SkillSourceIdentity, + host_snapshot: Option>, + mcp_resources: Option>, + query: Option, +} + +impl SkillSource for BoundSkillProvider { + fn kind(&self) -> SkillSourceKind { + self.kind.clone() + } + + fn identity(&self) -> SkillSourceIdentity { + self.identity.clone() + } + + fn list(&self) -> SkillSourceFuture<'_, SkillCatalog> { + let Some(query) = self.query.clone() else { + return Box::pin(async { + Err(SkillProviderError::new( + "skill source was not bound for catalog listing", + )) + }); + }; + self.provider.list(query) + } + + fn read(&self, request: RuntimeSkillReadRequest) -> SkillSourceFuture<'_, SkillReadResult> { + self.provider.read(SkillReadRequest { + authority: request.authority, + package: request.package, + resource: request.resource, + host_snapshot: self.host_snapshot.clone(), + mcp_resources: self.mcp_resources.clone(), + }) } }