Skip to main content

brainos_intent/
lib.rs

1//! # Brain Intent — Standardized Intent Token (SIT) schema
2//!
3//! Provides the universal envelope (`IntentToken`) that classifiers emit and
4//! routers resolve into concrete tool invocations. The schema is wire-stable:
5//! the [`SCHEMA`] constant identifies the version of the envelope shape.
6//!
7//! This crate is intentionally dependency-light — it defines data types and
8//! trait signatures only. Concrete classifier, router, registry and index
9//! implementations live in higher-level crates that compose this schema.
10
11use std::collections::{BTreeMap, HashMap};
12use std::sync::{Arc, RwLock};
13
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use uuid::Uuid;
19
20/// Wire identifier for the current SIT envelope. Bumped on any
21/// breaking change to [`IntentToken`].
22pub const SCHEMA: &str = "intent-token/1";
23
24// ─── Errors ─────────────────────────────────────────────────────────────────
25
26#[derive(Debug, Error)]
27pub enum IntentError {
28    #[error("unknown verb: {0}.{1}")]
29    UnknownVerb(String, String),
30
31    #[error("missing capability: {0}")]
32    MissingCapability(String),
33
34    #[error("constraint failed: {0}")]
35    ConstraintFailed(String),
36
37    #[error("schema mismatch: {0}")]
38    Schema(String),
39
40    #[error("unknown tool: {0}")]
41    UnknownTool(String),
42
43    #[error(transparent)]
44    Serde(#[from] serde_json::Error),
45}
46
47// ─── Intent token ───────────────────────────────────────────────────────────
48
49/// The universal envelope that flows from a classifier through a router into
50/// an executor. `verb` + `object` describe *what* the caller wants;
51/// `required_capabilities` and `constraints` describe *under what conditions*
52/// the action may run; `provenance` records *who asked*.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct IntentToken {
55    pub schema: String,
56    pub id: Uuid,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub parent_id: Option<Uuid>,
59    pub verb: Verb,
60    pub object: Object,
61    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62    pub modifiers: BTreeMap<String, serde_json::Value>,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub required_capabilities: Vec<String>,
65    pub provenance: Provenance,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub confidence: Option<f32>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub constraints: Vec<Constraint>,
70    pub namespace: String,
71}
72
73impl IntentToken {
74    /// Build a new token with a fresh id and the current schema string.
75    pub fn new(verb: Verb, object: Object, provenance: Provenance, namespace: String) -> Self {
76        Self {
77            schema: SCHEMA.to_string(),
78            id: Uuid::new_v4(),
79            parent_id: None,
80            verb,
81            object,
82            modifiers: BTreeMap::new(),
83            required_capabilities: Vec::new(),
84            provenance,
85            confidence: None,
86            constraints: Vec::new(),
87            namespace,
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub struct Verb {
94    pub namespace: String,
95    pub action: String,
96}
97
98impl Verb {
99    pub fn new(namespace: impl Into<String>, action: impl Into<String>) -> Self {
100        Self {
101            namespace: namespace.into(),
102            action: action.into(),
103        }
104    }
105
106    /// Dotted-string rendering (`memory.store`) for manifests and logs.
107    pub fn dotted(&self) -> String {
108        format!("{}.{}", self.namespace, self.action)
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct Object {
114    pub kind: String,
115    pub value: serde_json::Value,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(tag = "source", rename_all = "snake_case")]
120pub enum Provenance {
121    User {
122        raw_input: String,
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        ui_origin: Option<String>,
125        ts: DateTime<Utc>,
126    },
127    Llm {
128        model: String,
129        call_id: String,
130        #[serde(default, skip_serializing_if = "Option::is_none")]
131        raw_input: Option<String>,
132        ts: DateTime<Utc>,
133    },
134    Reflex {
135        trigger: String,
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        raw_input: Option<String>,
138        ts: DateTime<Utc>,
139    },
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(tag = "op", rename_all = "snake_case")]
144pub enum Constraint {
145    PathExists {
146        path: String,
147    },
148    NetReachable {
149        host: String,
150        port: u16,
151    },
152    EnvSet {
153        name: String,
154    },
155    UserPresent,
156    Custom {
157        name: String,
158        args: serde_json::Value,
159    },
160}
161
162// ─── Tool / route types ─────────────────────────────────────────────────────
163
164/// Opaque identifier for a native action backend (`memory`, `web_search`, …).
165/// Kept as a newtype to keep `ToolRoute` self-describing without forcing the
166/// schema crate to know about every backend in the workspace.
167#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
168pub struct BackendId(pub String);
169
170impl BackendId {
171    pub fn new(id: impl Into<String>) -> Self {
172        Self(id.into())
173    }
174
175    pub fn as_str(&self) -> &str {
176        &self.0
177    }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "snake_case")]
182pub enum ToolSource {
183    McpServer { server: String },
184    NativeBackend { backend: BackendId },
185    Terminal,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct ToolAnnotations {
190    #[serde(default)]
191    pub read_only_hint: bool,
192    #[serde(default)]
193    pub destructive_hint: bool,
194    #[serde(default)]
195    pub idempotent_hint: bool,
196}
197
198/// Reasoner-facing usage guidance for a capability. This is
199/// what lets a *planner* or the chat reasoner choose the right tool instead
200/// of guessing from a one-line description: when it applies, when it does
201/// not, what must be true first, roughly what it costs, and the safety
202/// `tier` the action carries (awareness ≠ permission — execution is still
203/// gated by the consent/audit path; this string is purely descriptive).
204///
205/// Every field is optional so the struct is backward-compatible on the wire
206/// (`#[serde(default)]` on the `ToolDescriptor::usage` field) and cheap to
207/// fill incrementally — MCP-sourced tools may carry none of it, a
208/// hand-authored native backend descriptor may carry all of it.
209///
210/// Free-text fields sourced from an untrusted MCP server are subject to the
211/// same injection caveat as [`ToolDescriptor::description`]; render them
212/// through [`sanitization`] before they reach a system prompt.
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214pub struct ToolUsage {
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub when_to_use: Option<String>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub when_not_to: Option<String>,
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub preconditions: Vec<String>,
221    /// Coarse cost hint — e.g. "free / local", "network call", "LLM tokens".
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub cost: Option<String>,
224    /// A short example invocation or phrasing that triggers this capability.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub example: Option<String>,
227    /// Safety tier as a string (`"read"`, `"write"`, `"execute"`,
228    /// `"external"`, `"destructive"`). Kept as a plain string so the schema
229    /// crate stays free of an `identity` dependency; registration sites
230    /// stamp it from the known [`Tier`](identity) equivalent.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub tier: Option<String>,
233}
234
235impl ToolUsage {
236    /// True when no guidance has been supplied — lets producers skip the
237    /// field on the wire so MCP-sourced tools that carry none stay tidy.
238    pub fn is_empty(&self) -> bool {
239        self.when_to_use.is_none()
240            && self.when_not_to.is_none()
241            && self.preconditions.is_empty()
242            && self.cost.is_none()
243            && self.example.is_none()
244            && self.tier.is_none()
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ToolDescriptor {
250    pub tool_id: String,
251    pub source: ToolSource,
252    pub verb: Verb,
253    /// **UNTRUSTED.** When sourced from an MCP server this string is
254    /// attacker-controllable (CVE-2025-54136 / "MCPoison" class — a
255    /// hostile server can ship a description crafted to inject
256    /// instructions into the user's LLM context). Never inline this
257    /// directly into a system prompt; route it through
258    /// [`sanitization::render_tool_description_for_prompt`] which
259    /// strips control bytes, caps length, and fences the body inside
260    /// a labeled untrusted block. The hash-pin layer in
261    /// `brainos-mcphost` detects rug-pull *changes* to this field;
262    /// the sanitizer is what stops a single hostile description from
263    /// landing as live system instructions.
264    pub description: String,
265    pub input_schema: serde_json::Value,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub output_schema: Option<serde_json::Value>,
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub capabilities: Vec<String>,
270    #[serde(default)]
271    pub annotations: ToolAnnotations,
272    /// Reasoner-facing usage guidance. Optional + defaulted
273    /// so existing producers/consumers stay wire-compatible.
274    #[serde(default, skip_serializing_if = "ToolUsage::is_empty")]
275    pub usage: ToolUsage,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub embedding: Option<Vec<f32>>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(tag = "kind", rename_all = "snake_case")]
282pub enum ToolRoute {
283    Mcp {
284        server: String,
285        tool: String,
286    },
287    NativeBackend {
288        backend: BackendId,
289    },
290    Terminal {
291        #[serde(default, skip_serializing_if = "Option::is_none")]
292        session_hint: Option<String>,
293    },
294    HumanConfirm {
295        ask: String,
296    },
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ScoredTool {
301    pub tool: ToolDescriptor,
302    pub score: f32,
303}
304
305// ─── Traits ─────────────────────────────────────────────────────────────────
306
307/// Resolves a token into a concrete invocation target. Implementations score
308/// candidates from a [`CapabilityIndex`] and return the best route.
309#[async_trait]
310pub trait IntentRouter: Send + Sync {
311    async fn resolve(&self, tok: &IntentToken) -> Result<ToolRoute, IntentError>;
312}
313
314/// Authoritative catalog of every tool the host can dispatch to. The MCP host
315/// and native backends register here on mount; the router queries here when
316/// resolving a token.
317#[async_trait]
318pub trait ToolRegistry: Send + Sync {
319    async fn register(&self, descriptor: ToolDescriptor) -> Result<(), IntentError>;
320    async fn deregister(&self, tool_id: &str) -> Result<(), IntentError>;
321    async fn list(&self) -> Vec<ToolDescriptor>;
322    async fn get(&self, tool_id: &str) -> Option<ToolDescriptor>;
323}
324
325/// Semantic top-k search over the registered tools. The default scoring
326/// strategy combines verb match, capability overlap, and embedding similarity
327/// against the token's surface form.
328#[async_trait]
329pub trait CapabilityIndex: Send + Sync {
330    async fn search(&self, q: &str, caps: &[String], k: usize) -> Vec<ScoredTool>;
331    async fn upsert(&self, t: &ToolDescriptor) -> Result<(), IntentError>;
332}
333
334/// Per-tool circuit-breaker snapshot. The router calls this for every
335/// candidate during [`IntentRouter::resolve`]; tools whose breaker is
336/// `Open` are excluded from scoring so a recently-failing tool can't be
337/// chosen until the cooldown elapses. The concrete breaker lives in
338/// `brainos-resilience`; the trait keeps the schema crate independent
339/// of the resilience layer.
340#[async_trait]
341pub trait BreakerCheck: Send + Sync {
342    async fn is_open(&self, tool_id: &str) -> bool;
343}
344
345// ─── Default implementations ────────────────────────────────────────────────
346
347/// Default [`IntentRouter`] that scores registered tools against a token and
348/// returns the highest-ranked candidate's route. Pure-data routing — no
349/// embeddings (the semantic [`CapabilityIndex`] lands in a separate slice);
350/// this router relies on verb match plus capability overlap.
351///
352/// Scoring (higher is better):
353/// - exact verb match (namespace + action) → `+2.0`
354/// - namespace-only match → `+1.0`
355/// - MCP coarse-verb fallback (`mcp.<tool_name>` matches the SIT's action) → `+0.5`
356/// - Jaccard overlap of `required_capabilities` × `tool.capabilities` → `× 1.5`
357///
358/// When no tool clears `0.0`, the router emits
359/// `ToolRoute::HumanConfirm { ask }` describing the unresolved verb — the
360/// pipeline surfaces that back to the user instead of guessing.
361pub struct DefaultIntentRouter {
362    registry: Arc<dyn ToolRegistry>,
363    breakers: Option<Arc<dyn BreakerCheck>>,
364}
365
366impl DefaultIntentRouter {
367    pub fn new(registry: Arc<dyn ToolRegistry>) -> Self {
368        Self {
369            registry,
370            breakers: None,
371        }
372    }
373
374    /// Wire a [`BreakerCheck`] so `Open` tools are excluded from scoring.
375    /// Without one, every registered tool is considered.
376    pub fn with_breakers(mut self, breakers: Arc<dyn BreakerCheck>) -> Self {
377        self.breakers = Some(breakers);
378        self
379    }
380
381    /// Score a single candidate against the token. Public so callers /
382    /// tests can probe the ranking without invoking `resolve`.
383    pub fn score(tok: &IntentToken, tool: &ToolDescriptor) -> f32 {
384        let mut score = 0.0_f32;
385        if tok.verb == tool.verb {
386            score += 2.0;
387        } else if tok.verb.namespace == tool.verb.namespace {
388            score += 1.0;
389        }
390        if tool.verb.namespace == "mcp" && tool.verb.action == tok.verb.action {
391            score += 0.5;
392        }
393        score += jaccard(&tok.required_capabilities, &tool.capabilities) * 1.5;
394        score
395    }
396}
397
398#[async_trait]
399impl IntentRouter for DefaultIntentRouter {
400    async fn resolve(&self, tok: &IntentToken) -> Result<ToolRoute, IntentError> {
401        let tools = self.registry.list().await;
402        let mut best: Option<(ToolDescriptor, f32)> = None;
403        for t in tools {
404            if let Some(breakers) = &self.breakers {
405                if breakers.is_open(&t.tool_id).await {
406                    continue;
407                }
408            }
409            let s = Self::score(tok, &t);
410            match best {
411                None if s > 0.0 => best = Some((t, s)),
412                Some((_, b)) if s > b => best = Some((t, s)),
413                _ => {}
414            }
415        }
416        Ok(match best {
417            Some((tool, _)) => route_for(&tool),
418            None => ToolRoute::HumanConfirm {
419                ask: format!(
420                    "No tool registered for verb '{}.{}' — review the capability registry or add a matching backend.",
421                    tok.verb.namespace, tok.verb.action,
422                ),
423            },
424        })
425    }
426}
427
428fn route_for(tool: &ToolDescriptor) -> ToolRoute {
429    match &tool.source {
430        ToolSource::McpServer { server } => ToolRoute::Mcp {
431            server: server.clone(),
432            // MCP tools registered by `mcphost` stamp the wire tool name into
433            // the verb's action slot, so the canonical handle stays
434            // round-trippable without re-parsing `tool_id`.
435            tool: tool.verb.action.clone(),
436        },
437        ToolSource::NativeBackend { backend } => ToolRoute::NativeBackend {
438            backend: backend.clone(),
439        },
440        ToolSource::Terminal => ToolRoute::Terminal { session_hint: None },
441    }
442}
443
444fn jaccard(a: &[String], b: &[String]) -> f32 {
445    if a.is_empty() && b.is_empty() {
446        return 0.0;
447    }
448    let set_a: std::collections::HashSet<&str> = a.iter().map(String::as_str).collect();
449    let set_b: std::collections::HashSet<&str> = b.iter().map(String::as_str).collect();
450    let intersection = set_a.intersection(&set_b).count() as f32;
451    let union = set_a.union(&set_b).count() as f32;
452    if union == 0.0 {
453        0.0
454    } else {
455        intersection / union
456    }
457}
458
459// ─── In-memory tool registry ────────────────────────────────────────────────
460
461/// In-memory [`ToolRegistry`] backed by a `RwLock<HashMap>`. The default
462/// registry the MCP host and native backends register into on mount; the
463/// router queries this when resolving an [`IntentToken`].
464#[derive(Default)]
465pub struct InMemoryToolRegistry {
466    tools: RwLock<HashMap<String, ToolDescriptor>>,
467}
468
469impl InMemoryToolRegistry {
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    /// Number of currently-registered tools. Useful for tests and metrics.
475    pub fn len(&self) -> usize {
476        self.tools.read().expect("registry lock poisoned").len()
477    }
478
479    pub fn is_empty(&self) -> bool {
480        self.tools
481            .read()
482            .expect("registry lock poisoned")
483            .is_empty()
484    }
485}
486
487#[async_trait]
488impl ToolRegistry for InMemoryToolRegistry {
489    async fn register(&self, descriptor: ToolDescriptor) -> Result<(), IntentError> {
490        let mut tools = self.tools.write().expect("registry lock poisoned");
491        tools.insert(descriptor.tool_id.clone(), descriptor);
492        Ok(())
493    }
494
495    async fn deregister(&self, tool_id: &str) -> Result<(), IntentError> {
496        let mut tools = self.tools.write().expect("registry lock poisoned");
497        if tools.remove(tool_id).is_none() {
498            return Err(IntentError::UnknownTool(tool_id.to_string()));
499        }
500        Ok(())
501    }
502
503    async fn list(&self) -> Vec<ToolDescriptor> {
504        self.tools
505            .read()
506            .expect("registry lock poisoned")
507            .values()
508            .cloned()
509            .collect()
510    }
511
512    async fn get(&self, tool_id: &str) -> Option<ToolDescriptor> {
513        self.tools
514            .read()
515            .expect("registry lock poisoned")
516            .get(tool_id)
517            .cloned()
518    }
519}
520
521pub mod sanitization;
522pub mod verbs;
523
524#[cfg(test)]
525mod tests;