1use 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
20pub const SCHEMA: &str = "intent-token/1";
23
24#[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#[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 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 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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub cost: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub example: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub tier: Option<String>,
233}
234
235impl ToolUsage {
236 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 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 #[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#[async_trait]
310pub trait IntentRouter: Send + Sync {
311 async fn resolve(&self, tok: &IntentToken) -> Result<ToolRoute, IntentError>;
312}
313
314#[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#[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#[async_trait]
341pub trait BreakerCheck: Send + Sync {
342 async fn is_open(&self, tool_id: &str) -> bool;
343}
344
345pub 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 pub fn with_breakers(mut self, breakers: Arc<dyn BreakerCheck>) -> Self {
377 self.breakers = Some(breakers);
378 self
379 }
380
381 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 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#[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 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;