Skip to main content

meerkat_capabilities/
lib.rs

1//! Feature-owned capability declarations and registry for Meerkat.
2
3use std::{borrow::Cow, str::FromStr};
4
5use meerkat_core::Config;
6use serde::{Deserialize, Serialize};
7
8/// Every capability known to Meerkat. Adding a variant forces updates to
9/// the registry, error mappings, and codegen templates.
10#[derive(
11    Debug,
12    Clone,
13    Copy,
14    PartialEq,
15    Eq,
16    PartialOrd,
17    Ord,
18    Hash,
19    Serialize,
20    Deserialize,
21    strum::EnumIter,
22    strum::EnumString,
23    strum::Display,
24)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[serde(rename_all = "snake_case")]
27#[strum(serialize_all = "snake_case")]
28pub enum CapabilityId {
29    Sessions,
30    Streaming,
31    StructuredOutput,
32    Hooks,
33    Builtins,
34    Shell,
35    Comms,
36    MemoryStore,
37    Schedule,
38    WorkGraph,
39    SessionStore,
40    SessionCompaction,
41    Skills,
42    McpLive,
43    /// Adaptive mobpack flow execution (FlowMaster planning loop, layer
44    /// compilation, policy composition). Stamped into a mobpack's
45    /// `[requires]` section by the pack builder whenever the manifest
46    /// declares an `[adaptive]` section, so hosts that do not know this
47    /// capability fail closed instead of silently downgrading the pack.
48    AdaptiveFlow,
49}
50
51/// A mobpack manifest capability token paired with its typed classification.
52///
53/// This is the parse-once classifier used at the manifest boundary: a raw
54/// token is classified into a [`MobpackCapabilityId`] exactly once, and policy
55/// decisions take the typed id.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct MobpackCapabilityRequirement<'a> {
58    raw: &'a str,
59    id: MobpackCapabilityId,
60}
61
62impl<'a> MobpackCapabilityRequirement<'a> {
63    pub fn parse(raw: &'a str) -> Self {
64        let id = CapabilityId::from_str(raw).map_or_else(
65            |_| {
66                HostProcessCapabilityId::parse(raw)
67                    .map(MobpackCapabilityId::HostProcess)
68                    .or_else(|| {
69                        DeploySurfaceCapabilityId::parse(raw)
70                            .map(MobpackCapabilityId::DeploySurface)
71                    })
72                    .unwrap_or(MobpackCapabilityId::Unknown)
73            },
74            MobpackCapabilityId::Known,
75        );
76        Self { raw, id }
77    }
78
79    pub fn raw(self) -> &'a str {
80        self.raw
81    }
82
83    pub fn id(self) -> MobpackCapabilityId {
84        self.id
85    }
86}
87
88/// Typed identity for a mobpack capability requirement.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MobpackCapabilityId {
91    Known(CapabilityId),
92    HostProcess(HostProcessCapabilityId),
93    DeploySurface(DeploySurfaceCapabilityId),
94    Unknown,
95}
96
97/// Whether a typed mobpack capability requirement is known to this host
98/// build's capability vocabulary.
99///
100/// This is the fail-closed knowledge gate used at mobpack load: a pack that
101/// requires a capability this build cannot even name (a future or vendor
102/// token classifying as [`MobpackCapabilityId::Unknown`]) must be rejected
103/// rather than silently downgraded. Satisfaction against the *runtime*
104/// capability set of a concrete deploy surface is a separate, stricter check
105/// owned by the deploying surface.
106///
107/// The match is exhaustive on purpose: adding a new requirement family to
108/// [`MobpackCapabilityId`] forces an explicit decision here.
109pub fn mobpack_capability_known_to_host(capability: MobpackCapabilityId) -> bool {
110    match capability {
111        MobpackCapabilityId::Known(_)
112        | MobpackCapabilityId::HostProcess(_)
113        | MobpackCapabilityId::DeploySurface(_) => true,
114        MobpackCapabilityId::Unknown => false,
115    }
116}
117
118/// Every mobpack capability token known to this host build, for diagnostics
119/// when a pack requires a capability outside the vocabulary.
120///
121/// Driven by the enum iterators so a new variant in any of the three
122/// requirement families is included automatically (its token spelling is
123/// already forced by the exhaustive `Display`/`as_str` matches).
124pub fn known_mobpack_capability_tokens() -> Vec<String> {
125    let mut tokens: Vec<String> = <CapabilityId as strum::IntoEnumIterator>::iter()
126        .map(|id| id.to_string())
127        .collect();
128    tokens.extend(
129        <HostProcessCapabilityId as strum::IntoEnumIterator>::iter()
130            .map(|id| id.as_str().to_string()),
131    );
132    tokens.extend(
133        <DeploySurfaceCapabilityId as strum::IntoEnumIterator>::iter()
134            .map(|id| id.as_str().to_string()),
135    );
136    tokens
137}
138
139/// Deploy-surface capabilities named by mobpack manifests.
140///
141/// These name the runtime surface a deployed mob is hosted on (`core`,
142/// `mcp`, `rpc`), distinct from the feature-capability vocabulary in
143/// [`CapabilityId`].
144#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
145pub enum DeploySurfaceCapabilityId {
146    Core,
147    Mcp,
148    Rpc,
149}
150
151impl DeploySurfaceCapabilityId {
152    pub fn parse(raw: &str) -> Option<Self> {
153        match raw {
154            "core" => Some(Self::Core),
155            "mcp" => Some(Self::Mcp),
156            "rpc" => Some(Self::Rpc),
157            _ => None,
158        }
159    }
160
161    pub fn as_str(self) -> &'static str {
162        match self {
163            Self::Core => "core",
164            Self::Mcp => "mcp",
165            Self::Rpc => "rpc",
166        }
167    }
168}
169
170/// Host process capabilities named by existing mobpack manifests.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
172pub enum HostProcessCapabilityId {
173    McpStdio,
174    ProcessSpawn,
175}
176
177impl HostProcessCapabilityId {
178    pub fn parse(raw: &str) -> Option<Self> {
179        match raw {
180            "mcp_stdio" => Some(Self::McpStdio),
181            "process_spawn" => Some(Self::ProcessSpawn),
182            _ => None,
183        }
184    }
185
186    pub fn as_str(self) -> &'static str {
187        match self {
188            Self::McpStdio => "mcp_stdio",
189            Self::ProcessSpawn => "process_spawn",
190        }
191    }
192}
193
194/// Browser mobpack policy decision for a typed capability requirement.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum BrowserMobpackCapabilityDecision {
197    Allowed,
198    Forbidden { capability: MobpackCapabilityId },
199}
200
201impl BrowserMobpackCapabilityDecision {
202    pub fn is_forbidden(self) -> bool {
203        matches!(self, Self::Forbidden { .. })
204    }
205}
206
207pub fn browser_mobpack_capability_decision(
208    capability: MobpackCapabilityId,
209) -> BrowserMobpackCapabilityDecision {
210    match capability {
211        MobpackCapabilityId::Known(CapabilityId::Shell) | MobpackCapabilityId::HostProcess(_) => {
212            BrowserMobpackCapabilityDecision::Forbidden { capability }
213        }
214        MobpackCapabilityId::Known(_)
215        | MobpackCapabilityId::DeploySurface(_)
216        | MobpackCapabilityId::Unknown => BrowserMobpackCapabilityDecision::Allowed,
217    }
218}
219
220/// Protocol surfaces used only for capability declaration metadata.
221#[derive(
222    Debug,
223    Clone,
224    Copy,
225    PartialEq,
226    Eq,
227    Hash,
228    Serialize,
229    Deserialize,
230    strum::EnumString,
231    strum::Display,
232)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234#[serde(rename_all = "snake_case")]
235#[strum(serialize_all = "snake_case")]
236pub enum CapabilityProtocol {
237    Rpc,
238    Rest,
239    Mcp,
240    Cli,
241}
242
243/// Where a capability applies.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub enum CapabilityScope {
247    /// Available on all protocol surfaces.
248    Universal,
249    /// Available only on specific protocols.
250    Extension {
251        protocols: Cow<'static, [CapabilityProtocol]>,
252    },
253}
254
255/// Runtime status of a capability.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258pub enum CapabilityStatus {
259    /// Compiled in, config-enabled, protocol supports it.
260    Available,
261    /// Compiled in but disabled by policy.
262    DisabledByPolicy { description: Cow<'static, str> },
263    /// Not compiled into this build (feature flag absent).
264    NotCompiled { feature: Cow<'static, str> },
265    /// This protocol surface doesn't support it.
266    NotSupportedByProtocol { reason: Cow<'static, str> },
267}
268
269#[derive(Clone, Copy)]
270pub struct FeatureCapabilityPolicy {
271    enabled: fn(&Config) -> bool,
272    disabled_description: &'static str,
273}
274
275impl FeatureCapabilityPolicy {
276    pub const fn new(enabled: fn(&Config) -> bool, disabled_description: &'static str) -> Self {
277        Self {
278            enabled,
279            disabled_description,
280        }
281    }
282
283    pub fn is_enabled(self, config: &Config) -> bool {
284        (self.enabled)(config)
285    }
286
287    pub const fn disabled_description(self) -> &'static str {
288        self.disabled_description
289    }
290}
291
292/// Self-registration entry for a capability.
293///
294/// Feature crates submit these via `inventory::submit!`.
295pub struct CapabilityRegistration {
296    pub id: CapabilityId,
297    pub description: &'static str,
298    pub scope: CapabilityScope,
299    pub requires_feature: Option<&'static str>,
300    pub prerequisites: &'static [CapabilityId],
301    pub status_resolver: Option<fn(&Config) -> CapabilityStatus>,
302}
303
304inventory::collect!(CapabilityRegistration);
305
306// Always-present capabilities (no feature gate, always compiled)
307inventory::submit! {
308    CapabilityRegistration {
309        id: CapabilityId::Sessions,
310        description: "Agent loop and session lifecycle",
311        scope: CapabilityScope::Universal,
312        requires_feature: None,
313        prerequisites: &[],
314        status_resolver: None,
315    }
316}
317
318inventory::submit! {
319    CapabilityRegistration {
320        id: CapabilityId::Streaming,
321        description: "Event streaming during agent execution",
322        scope: CapabilityScope::Universal,
323        requires_feature: None,
324        prerequisites: &[],
325        status_resolver: None,
326    }
327}
328
329inventory::submit! {
330    CapabilityRegistration {
331        id: CapabilityId::StructuredOutput,
332        description: "Schema-validated JSON output extraction",
333        scope: CapabilityScope::Universal,
334        requires_feature: None,
335        prerequisites: &[],
336        status_resolver: None,
337    }
338}
339
340/// Collect all registered capabilities, sorted by [`CapabilityId`] ordinal
341/// for deterministic ordering regardless of `inventory` collection order.
342pub fn build_capabilities() -> Vec<&'static CapabilityRegistration> {
343    let mut caps: Vec<&'static CapabilityRegistration> = inventory::iter::<CapabilityRegistration>
344        .into_iter()
345        .collect();
346    caps.sort_by_key(|r| r.id);
347    caps
348}
349
350/// Resolve runtime status for every registered capability against the current
351/// config. This is the single config-aware capability truth used by both
352/// surface reporting and skill filtering.
353pub fn resolve_capabilities(
354    config: &Config,
355) -> Vec<(&'static CapabilityRegistration, CapabilityStatus)> {
356    build_capabilities()
357        .into_iter()
358        .map(|reg| {
359            let status = match reg.status_resolver {
360                Some(resolver) => resolver(config),
361                None => CapabilityStatus::Available,
362            };
363            (reg, status)
364        })
365        .collect()
366}
367
368/// Return the capability ids that are effectively available after config-level
369/// status resolution has been applied.
370pub fn available_capabilities(config: &Config) -> Vec<CapabilityId> {
371    resolve_capabilities(config)
372        .into_iter()
373        .filter_map(|(reg, status)| matches!(status, CapabilityStatus::Available).then_some(reg.id))
374        .collect()
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use meerkat_core::Config;
381
382    #[test]
383    fn test_build_capabilities_finds_registered() {
384        let caps = build_capabilities();
385        assert!(
386            caps.iter().any(|c| c.id == CapabilityId::Sessions),
387            "Should find the test-registered Sessions capability"
388        );
389    }
390
391    #[test]
392    fn test_build_capabilities_sorted() {
393        let caps = build_capabilities();
394        if caps.len() >= 2 {
395            for window in caps.windows(2) {
396                assert!(
397                    window[0].id <= window[1].id,
398                    "Capabilities should be sorted by ordinal"
399                );
400            }
401        }
402    }
403
404    #[test]
405    fn available_capabilities_always_include_unconditional_entries() {
406        let config = Config::default();
407        let caps = available_capabilities(&config);
408        assert!(caps.contains(&CapabilityId::Sessions));
409        assert!(caps.contains(&CapabilityId::Streaming));
410        assert!(caps.contains(&CapabilityId::StructuredOutput));
411    }
412
413    #[test]
414    fn mobpack_capability_requirement_classifies_known_capabilities() {
415        let requirement = MobpackCapabilityRequirement::parse("comms");
416
417        assert_eq!(
418            requirement.id(),
419            MobpackCapabilityId::Known(CapabilityId::Comms)
420        );
421        assert_eq!(requirement.raw(), "comms");
422    }
423
424    #[test]
425    fn mobpack_capability_requirement_classifies_host_process_capabilities() {
426        assert_eq!(
427            MobpackCapabilityRequirement::parse("mcp_stdio").id(),
428            MobpackCapabilityId::HostProcess(HostProcessCapabilityId::McpStdio)
429        );
430        assert_eq!(
431            MobpackCapabilityRequirement::parse("process_spawn").id(),
432            MobpackCapabilityId::HostProcess(HostProcessCapabilityId::ProcessSpawn)
433        );
434    }
435
436    #[test]
437    fn browser_mobpack_policy_forbids_shell_and_host_process_capabilities() {
438        for raw in ["shell", "mcp_stdio", "process_spawn"] {
439            assert!(
440                browser_mobpack_capability_decision(MobpackCapabilityRequirement::parse(raw).id())
441                    .is_forbidden(),
442                "{raw} should be forbidden in browser mobpacks"
443            );
444        }
445    }
446
447    #[test]
448    fn adaptive_flow_classifies_as_known_capability() {
449        let requirement = MobpackCapabilityRequirement::parse("adaptive_flow");
450        assert_eq!(
451            requirement.id(),
452            MobpackCapabilityId::Known(CapabilityId::AdaptiveFlow)
453        );
454        assert_eq!(CapabilityId::AdaptiveFlow.to_string(), "adaptive_flow");
455    }
456
457    #[test]
458    fn host_knows_every_typed_capability_and_rejects_unknown() {
459        for raw in ["comms", "adaptive_flow", "mcp_stdio", "core"] {
460            assert!(
461                mobpack_capability_known_to_host(MobpackCapabilityRequirement::parse(raw).id()),
462                "{raw} must be known to this host build"
463            );
464        }
465        assert!(!mobpack_capability_known_to_host(
466            MobpackCapabilityRequirement::parse("capability-from-the-future").id()
467        ));
468    }
469
470    #[test]
471    fn known_tokens_cover_all_requirement_families_and_round_trip() {
472        let tokens = known_mobpack_capability_tokens();
473        for expected in [
474            "sessions",
475            "adaptive_flow",
476            "mcp_stdio",
477            "process_spawn",
478            "core",
479        ] {
480            assert!(
481                tokens.iter().any(|t| t == expected),
482                "known token set must contain {expected}: {tokens:?}"
483            );
484        }
485        // Every advertised token must classify back into a known typed id.
486        for token in &tokens {
487            assert!(
488                mobpack_capability_known_to_host(MobpackCapabilityRequirement::parse(token).id()),
489                "advertised token {token} must round-trip as known"
490            );
491        }
492    }
493
494    #[test]
495    fn browser_mobpack_policy_allows_safe_known_and_unknown_capabilities() {
496        assert_eq!(
497            browser_mobpack_capability_decision(MobpackCapabilityRequirement::parse("comms").id()),
498            BrowserMobpackCapabilityDecision::Allowed
499        );
500        assert_eq!(
501            browser_mobpack_capability_decision(
502                MobpackCapabilityRequirement::parse("vendor.custom").id()
503            ),
504            BrowserMobpackCapabilityDecision::Allowed
505        );
506    }
507}