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.read().expect("registry poisoned").len()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn register_and_list() {
169        let r = AgentCapabilityRegistry::new();
170        r.register("summarize", "alpha");
171        r.register("summarize", "beta");
172        r.register("transcribe-audio", "alpha");
173        assert_eq!(r.agents_for("summarize"), vec!["alpha", "beta"]);
174        assert_eq!(r.agents_for("transcribe-audio"), vec!["alpha"]);
175        assert!(r.agents_for("nonexistent").is_empty());
176        assert_eq!(r.all_agents(), vec!["alpha", "beta"]);
177        assert_eq!(r.capability_count(), 2);
178    }
179
180    #[test]
181    fn register_is_idempotent() {
182        let r = AgentCapabilityRegistry::new();
183        r.register("summarize", "alpha");
184        r.register("summarize", "alpha");
185        r.register("summarize", "alpha");
186        assert_eq!(r.agents_for("summarize"), vec!["alpha"]);
187    }
188
189    #[test]
190    fn select_honors_hint() {
191        let r = AgentCapabilityRegistry::new();
192        r.register("summarize", "alpha");
193        r.register("summarize", "beta");
194        r.register("summarize", "gamma");
195        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
196        // Hint that doesn't match → fall through to MRU/insertion.
197        assert_eq!(
198            r.select("summarize", Some("nonexistent")),
199            Some("alpha".into())
200        );
201    }
202
203    #[test]
204    fn select_uses_mru_when_no_hint() {
205        let r = AgentCapabilityRegistry::new();
206        r.register("summarize", "alpha");
207        r.register("summarize", "beta");
208        // No usage yet → first registered (alpha).
209        assert_eq!(r.select("summarize", None), Some("alpha".into()));
210        // Touch beta → it becomes MRU.
211        r.note_used("summarize", "beta");
212        assert_eq!(r.select("summarize", None), Some("beta".into()));
213        // Touch alpha later → it wins again.
214        std::thread::sleep(std::time::Duration::from_millis(2));
215        r.note_used("summarize", "alpha");
216        assert_eq!(r.select("summarize", None), Some("alpha".into()));
217    }
218
219    #[test]
220    fn unregister_removes() {
221        let r = AgentCapabilityRegistry::new();
222        r.register("summarize", "alpha");
223        r.register("summarize", "beta");
224        r.unregister("summarize", "alpha");
225        assert_eq!(r.agents_for("summarize"), vec!["beta"]);
226        r.unregister("summarize", "beta");
227        assert!(r.agents_for("summarize").is_empty());
228        assert_eq!(r.capability_count(), 0);
229    }
230
231    #[test]
232    fn select_returns_none_for_unknown_capability() {
233        let r = AgentCapabilityRegistry::new();
234        assert_eq!(r.select("nope", None), None);
235    }
236
237    /// Three-agent tiebreak: with one agent touched and two at
238    /// `last_used = None`, the touched one wins; with all three
239    /// untouched, first-registered wins; touching a different one
240    /// flips MRU. Locks the strict-greater fold semantics —
241    /// without it the impl could regress to `max_by_key` and
242    /// silently invert insertion order on ties.
243    #[test]
244    fn select_three_agent_tiebreak() {
245        let r = AgentCapabilityRegistry::new();
246        r.register("summarize", "alpha");
247        r.register("summarize", "beta");
248        r.register("summarize", "gamma");
249
250        // All untouched → first-registered wins.
251        assert_eq!(r.select("summarize", None), Some("alpha".into()));
252
253        // Touch gamma → gamma wins; alpha and beta still tied at None.
254        r.note_used("summarize", "gamma");
255        assert_eq!(r.select("summarize", None), Some("gamma".into()));
256
257        // Touch alpha later → alpha wins now.
258        std::thread::sleep(std::time::Duration::from_millis(2));
259        r.note_used("summarize", "alpha");
260        assert_eq!(r.select("summarize", None), Some("alpha".into()));
261
262        // Hint still wins over MRU.
263        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
264    }
265}