Skip to main content

car_engine/
agent_capability.rs

1//! Agent capability registry — maps capability id → agents that implement it.
2//!
3//! Differs from sibling [`crate::capabilities`] (which restricts what tools
4//! an agent may call). This registry answers the inverse question: given
5//! a capability id from the agent-bundle vocabulary
6//! (`docs/agent-bundle-spec.md` — `transcribe-audio`, `summarize`, etc.),
7//! which installed agents implement it?
8//!
9//! Used by `invoke_capability` dispatch (Rust side) and by the host app's
10//! `DynamicOptionsProvider` (Swift side, via UniFFI's `list_agents`).
11//!
12//! ## Selection strategy
13//!
14//! When multiple agents advertise the same capability, [`select`] picks
15//! one in this order:
16//!
17//! 1. The caller-provided `hint` if it names a registered agent.
18//! 2. Most-recently-used agent for the capability.
19//! 3. First registered (insertion order).
20//!
21//! This is intentionally simple. A scoring-based selector (latency,
22//! quality, cost) belongs in `car-inference`'s adaptive router, not
23//! here — this layer only handles "which agent" once "which model"
24//! has already been resolved upstream.
25//!
26//! ## Persistence
27//!
28//! In-memory only for v1. The bundle install path (when it lands) will
29//! re-register on each runtime start. Last-used timestamps don't
30//! survive restart — pinning by hint is the durable signal.
31
32use std::collections::HashMap;
33use std::sync::RwLock;
34use std::time::Instant;
35
36/// One agent's registration for a capability.
37#[derive(Debug, Clone)]
38struct AgentEntry {
39    id: String,
40    /// When the agent last successfully served this capability. `None`
41    /// for newly-registered agents that haven't been invoked yet.
42    last_used: Option<Instant>,
43}
44
45/// Maps capability id → ordered list of agents that implement it.
46///
47/// Concurrent access via `RwLock`. Reads (the hot path during
48/// dispatch) are non-blocking under contention; writes (registration,
49/// usage tracking) are infrequent.
50#[derive(Debug, Default)]
51pub struct AgentCapabilityRegistry {
52    by_capability: RwLock<HashMap<String, Vec<AgentEntry>>>,
53}
54
55impl AgentCapabilityRegistry {
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Register `agent_id` as a provider for `capability`. Idempotent —
61    /// re-registering is a no-op (insertion order is preserved on the
62    /// first registration).
63    pub fn register(&self, capability: impl Into<String>, agent_id: impl Into<String>) {
64        let capability = capability.into();
65        let agent_id = agent_id.into();
66        let mut g = self.by_capability.write().expect("registry poisoned");
67        let entries = g.entry(capability).or_default();
68        if !entries.iter().any(|e| e.id == agent_id) {
69            entries.push(AgentEntry {
70                id: agent_id,
71                last_used: None,
72            });
73        }
74    }
75
76    /// Remove `agent_id` from `capability`. Used by the bundle uninstall
77    /// path (when it exists). Idempotent.
78    pub fn unregister(&self, capability: &str, agent_id: &str) {
79        let mut g = self.by_capability.write().expect("registry poisoned");
80        if let Some(entries) = g.get_mut(capability) {
81            entries.retain(|e| e.id != agent_id);
82            if entries.is_empty() {
83                g.remove(capability);
84            }
85        }
86    }
87
88    /// Snapshot of all agents registered for `capability`, in
89    /// registration order.
90    pub fn agents_for(&self, capability: &str) -> Vec<String> {
91        let g = self.by_capability.read().expect("registry poisoned");
92        g.get(capability)
93            .map(|v| v.iter().map(|e| e.id.clone()).collect())
94            .unwrap_or_default()
95    }
96
97    /// Snapshot of every agent registered for any capability,
98    /// deduplicated. Useful for the FFI surface's `list_agents(None)`
99    /// call.
100    pub fn all_agents(&self) -> Vec<String> {
101        let g = self.by_capability.read().expect("registry poisoned");
102        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
103        for entries in g.values() {
104            for e in entries {
105                seen.insert(e.id.clone());
106            }
107        }
108        seen.into_iter().collect()
109    }
110
111    /// Pick one agent for `capability`. Returns `None` only when no
112    /// agents are registered. See module docs for the selection
113    /// strategy.
114    pub fn select(&self, capability: &str, hint: Option<&str>) -> Option<String> {
115        let g = self.by_capability.read().expect("registry poisoned");
116        let entries = g.get(capability)?;
117        if entries.is_empty() {
118            return None;
119        }
120
121        // 1. Honor explicit hint if it matches a registered agent.
122        if let Some(h) = hint {
123            if let Some(e) = entries.iter().find(|e| e.id == h) {
124                return Some(e.id.clone());
125            }
126        }
127
128        // 2. Most-recently-used. We want: pick the agent with the
129        // newest `last_used`; if none have been used (or several tie
130        // at `None`), fall back to the first registered. `max_by_key`
131        // on an iterator returns the LAST max element when keys tie,
132        // which would invert insertion order — so we fold manually
133        // with strict-greater comparison.
134        let mut best: Option<&AgentEntry> = None;
135        for e in entries.iter() {
136            best = match best {
137                None => Some(e),
138                Some(b) if e.last_used > b.last_used => Some(e),
139                Some(b) => Some(b),
140            };
141        }
142        best.map(|e| e.id.clone())
143    }
144
145    /// Record that `agent_id` successfully served `capability`. Updates
146    /// the last-used timestamp for [`select`]'s MRU tiebreaker.
147    /// Silent no-op if the entry isn't registered.
148    pub fn note_used(&self, capability: &str, agent_id: &str) {
149        let mut g = self.by_capability.write().expect("registry poisoned");
150        if let Some(entries) = g.get_mut(capability) {
151            if let Some(e) = entries.iter_mut().find(|e| e.id == agent_id) {
152                e.last_used = Some(Instant::now());
153            }
154        }
155    }
156
157    /// Number of distinct capabilities tracked. For diagnostics.
158    pub fn capability_count(&self) -> usize {
159        self.by_capability
160            .read()
161            .expect("registry poisoned")
162            .len()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn register_and_list() {
172        let r = AgentCapabilityRegistry::new();
173        r.register("summarize", "alpha");
174        r.register("summarize", "beta");
175        r.register("transcribe-audio", "alpha");
176        assert_eq!(r.agents_for("summarize"), vec!["alpha", "beta"]);
177        assert_eq!(r.agents_for("transcribe-audio"), vec!["alpha"]);
178        assert!(r.agents_for("nonexistent").is_empty());
179        assert_eq!(r.all_agents(), vec!["alpha", "beta"]);
180        assert_eq!(r.capability_count(), 2);
181    }
182
183    #[test]
184    fn register_is_idempotent() {
185        let r = AgentCapabilityRegistry::new();
186        r.register("summarize", "alpha");
187        r.register("summarize", "alpha");
188        r.register("summarize", "alpha");
189        assert_eq!(r.agents_for("summarize"), vec!["alpha"]);
190    }
191
192    #[test]
193    fn select_honors_hint() {
194        let r = AgentCapabilityRegistry::new();
195        r.register("summarize", "alpha");
196        r.register("summarize", "beta");
197        r.register("summarize", "gamma");
198        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
199        // Hint that doesn't match → fall through to MRU/insertion.
200        assert_eq!(
201            r.select("summarize", Some("nonexistent")),
202            Some("alpha".into())
203        );
204    }
205
206    #[test]
207    fn select_uses_mru_when_no_hint() {
208        let r = AgentCapabilityRegistry::new();
209        r.register("summarize", "alpha");
210        r.register("summarize", "beta");
211        // No usage yet → first registered (alpha).
212        assert_eq!(r.select("summarize", None), Some("alpha".into()));
213        // Touch beta → it becomes MRU.
214        r.note_used("summarize", "beta");
215        assert_eq!(r.select("summarize", None), Some("beta".into()));
216        // Touch alpha later → it wins again.
217        std::thread::sleep(std::time::Duration::from_millis(2));
218        r.note_used("summarize", "alpha");
219        assert_eq!(r.select("summarize", None), Some("alpha".into()));
220    }
221
222    #[test]
223    fn unregister_removes() {
224        let r = AgentCapabilityRegistry::new();
225        r.register("summarize", "alpha");
226        r.register("summarize", "beta");
227        r.unregister("summarize", "alpha");
228        assert_eq!(r.agents_for("summarize"), vec!["beta"]);
229        r.unregister("summarize", "beta");
230        assert!(r.agents_for("summarize").is_empty());
231        assert_eq!(r.capability_count(), 0);
232    }
233
234    #[test]
235    fn select_returns_none_for_unknown_capability() {
236        let r = AgentCapabilityRegistry::new();
237        assert_eq!(r.select("nope", None), None);
238    }
239
240    /// Three-agent tiebreak: with one agent touched and two at
241    /// `last_used = None`, the touched one wins; with all three
242    /// untouched, first-registered wins; touching a different one
243    /// flips MRU. Locks the strict-greater fold semantics —
244    /// without it the impl could regress to `max_by_key` and
245    /// silently invert insertion order on ties.
246    #[test]
247    fn select_three_agent_tiebreak() {
248        let r = AgentCapabilityRegistry::new();
249        r.register("summarize", "alpha");
250        r.register("summarize", "beta");
251        r.register("summarize", "gamma");
252
253        // All untouched → first-registered wins.
254        assert_eq!(r.select("summarize", None), Some("alpha".into()));
255
256        // Touch gamma → gamma wins; alpha and beta still tied at None.
257        r.note_used("summarize", "gamma");
258        assert_eq!(r.select("summarize", None), Some("gamma".into()));
259
260        // Touch alpha later → alpha wins now.
261        std::thread::sleep(std::time::Duration::from_millis(2));
262        r.note_used("summarize", "alpha");
263        assert_eq!(r.select("summarize", None), Some("alpha".into()));
264
265        // Hint still wins over MRU.
266        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
267    }
268}