Skip to main content

brainos_backends/
capabilities.rs

1//! The declarative capability-registration substrate.
2//!
3//! Every native capability the kernel exposes is *declared* as data next to the
4//! backend logic that implements it: each backend module owns a
5//! `pub fn capabilities(&BrainConfig) -> Vec<ToolDescriptor>` that returns the
6//! descriptors it is wired for this run (applying its own config gate). The
7//! [`native_capabilities`] registrar here is the single collection point — it
8//! concatenates every backend's declaration into the one manifest that feeds
9//! both the internal reasoner (the SOUL capability digest) and external clients
10//! (`tools/list`). The composition root registers the result through the same
11//! [`intent::ToolRegistry::register`] path the MCP host uses on mount, so native
12//! backends, mounted MCP servers, and (future) skill packs are all peers.
13//!
14//! **Authoring lives with the logic, not in the binary.** Before this, the
15//! native catalog was a hand-built `Vec` inside the `cli` crate; adding a
16//! capability meant editing the binary. Now adding a capability touches only the
17//! backend that owns it. Capabilities whose *logic* is not in this crate (the
18//! terminal bridge, sandboxed exec, path-scoped filesystem read) are declared by
19//! the composition root that wires them, using the same [`native`] builder.
20//!
21//! **Awareness, not permission.** A descriptor only makes a capability
22//! *describable*; execution still flows through the consent/audit/breaker path.
23//! The `usage.tier` mirrors the verb's conservative [`intent::verbs`] tier hint
24//! so a reader sees the gate it will hit.
25
26use brain::BrainConfig;
27use intent::{verbs, BackendId, ToolAnnotations, ToolDescriptor, ToolSource, ToolUsage, Verb};
28
29/// Build a native [`ToolDescriptor`] for `(ns, action)`, pulling the description
30/// and tier from the canonical [`verbs`] vocabulary and layering the supplied
31/// usage guidance on top. The `tier` is filled from the verb hint when the
32/// caller leaves it unset. Shared by every backend declaration and by the
33/// composition root for the capabilities it owns directly.
34pub fn native(
35    ns: &str,
36    action: &str,
37    source: ToolSource,
38    annotations: ToolAnnotations,
39    mut usage: ToolUsage,
40) -> ToolDescriptor {
41    let spec = verbs::lookup(ns, action);
42    let description = spec.map(|s| s.summary.to_string()).unwrap_or_default();
43    if usage.tier.is_none() {
44        usage.tier = spec.map(|s| s.tier_hint.as_str().to_string());
45    }
46    ToolDescriptor {
47        // `native:` prefix mirrors the `mcp:{server}:{tool}` id shape so ids
48        // stay source-discriminable at a glance.
49        tool_id: format!("native:{ns}.{action}"),
50        source,
51        verb: Verb::new(ns, action),
52        description,
53        input_schema: serde_json::json!({ "type": "object" }),
54        output_schema: None,
55        capabilities: vec![format!("{ns}.{action}")],
56        annotations,
57        usage,
58        embedding: None,
59    }
60}
61
62/// A [`ToolSource::NativeBackend`] for the named backend. Convenience for
63/// declarations so each module names its own backend id once.
64pub fn backend(name: &str) -> ToolSource {
65    ToolSource::NativeBackend {
66        backend: BackendId::new(name),
67    }
68}
69
70/// Read-only, idempotent annotations.
71pub fn read_only() -> ToolAnnotations {
72    ToolAnnotations {
73        read_only_hint: true,
74        destructive_hint: false,
75        idempotent_hint: true,
76    }
77}
78
79/// Destructive, non-idempotent annotations.
80pub fn destructive() -> ToolAnnotations {
81    ToolAnnotations {
82        read_only_hint: false,
83        destructive_hint: true,
84        idempotent_hint: false,
85    }
86}
87
88/// Assemble reasoner-facing [`ToolUsage`] guidance. `tier` is left unset and
89/// filled from the verb hint by [`native`].
90pub fn usage(
91    when_to_use: &str,
92    when_not_to: &str,
93    preconditions: &[&str],
94    cost: &str,
95    example: &str,
96) -> ToolUsage {
97    ToolUsage {
98        when_to_use: Some(when_to_use.to_string()),
99        when_not_to: Some(when_not_to.to_string()),
100        preconditions: preconditions.iter().map(|s| s.to_string()).collect(),
101        cost: Some(cost.to_string()),
102        example: Some(example.to_string()),
103        tier: None,
104    }
105}
106
107/// The registrar: collect every native capability this crate's backends declare
108/// for the given config into one manifest. Each backend applies its own gate, so
109/// disabled backends contribute nothing. Pure (no I/O) so it can be unit-tested
110/// and so the composition root can both register it and snapshot it (baseline).
111pub fn native_capabilities(config: &BrainConfig) -> Vec<ToolDescriptor> {
112    let mut out = Vec::new();
113    out.extend(crate::memory::capabilities(config));
114    out.extend(crate::search::capabilities(config));
115    out.extend(crate::scheduling::capabilities(config));
116    out.extend(crate::messaging::capabilities(config));
117    out.extend(crate::net::capabilities(config));
118    out.extend(crate::security::capabilities(config));
119    out.extend(crate::logs::capabilities(config));
120    out.extend(crate::baseline::capabilities(config));
121    out
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::sync::Arc;
128
129    fn all_enabled() -> BrainConfig {
130        let mut c = BrainConfig::default();
131        c.actions.web_search.enabled = true;
132        c.actions.scheduling.enabled = true;
133        c.actions.messaging.enabled = true;
134        c
135    }
136
137    fn has(ds: &[ToolDescriptor], ns: &str, action: &str) -> bool {
138        ds.iter().any(|d| d.verb == Verb::new(ns, action))
139    }
140
141    /// Each always-on backend must declare at least its headline capability, so
142    /// a backend that ships logic but forgets to declare a descriptor is caught
143    /// here rather than going silently un-advertised.
144    #[test]
145    fn every_always_on_backend_declares_its_capability() {
146        let ds = native_capabilities(&all_enabled());
147        for (ns, action) in [
148            ("memory", "store"),
149            ("memory", "delete"),
150            ("net", "check"),
151            ("net", "trace"),
152            ("net", "cert"),
153            ("security", "audit"),
154            ("logs", "analyze"),
155            ("baseline", "capture"),
156            ("baseline", "diff"),
157            ("baseline", "list"),
158        ] {
159            assert!(has(&ds, ns, action), "{ns}.{action} not declared");
160        }
161    }
162
163    /// A backend whose config gate is off contributes nothing to the manifest.
164    #[test]
165    fn config_gates_drop_disabled_backends() {
166        let mut c = BrainConfig::default();
167        c.actions.web_search.enabled = false;
168        c.actions.scheduling.enabled = false;
169        c.actions.messaging.enabled = false;
170        let ds = native_capabilities(&c);
171        assert!(
172            !has(&ds, "net", "http"),
173            "web search disabled → no net.http"
174        );
175        assert!(!ds.iter().any(|d| d.verb.namespace == "schedule"));
176        assert!(!ds.iter().any(|d| d.verb.namespace == "notify"));
177        // Always-on diagnostics + memory survive the gate.
178        assert!(has(&ds, "net", "check"));
179        assert!(has(&ds, "memory", "store"));
180    }
181
182    /// Tool ids are unique across the assembled manifest — a collision would
183    /// silently overwrite a capability on registration.
184    #[test]
185    fn declared_tool_ids_are_unique() {
186        let ds = native_capabilities(&all_enabled());
187        let mut ids: Vec<&str> = ds.iter().map(|d| d.tool_id.as_str()).collect();
188        ids.sort_unstable();
189        let before = ids.len();
190        ids.dedup();
191        assert_eq!(before, ids.len(), "duplicate tool_id in native manifest");
192    }
193
194    /// Round-trip: every declared descriptor registers through the same
195    /// [`ToolRegistry::register`] path the MCP host uses, and is then
196    /// retrievable from the manifest by id.
197    #[tokio::test]
198    async fn declared_capabilities_register_and_are_retrievable() {
199        let ds = native_capabilities(&all_enabled());
200        let registry: Arc<dyn intent::ToolRegistry> = Arc::new(intent::InMemoryToolRegistry::new());
201        for d in &ds {
202            registry.register(d.clone()).await.unwrap();
203        }
204        assert_eq!(registry.list().await.len(), ds.len());
205        for d in &ds {
206            assert!(
207                registry.get(&d.tool_id).await.is_some(),
208                "{} not retrievable after registration",
209                d.tool_id
210            );
211        }
212    }
213}