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`](crate::agent_capability::AgentCapabilityRegistry::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 sequence numbers don't
30//! survive restart — pinning by hint is the durable signal.
31
32use std::collections::HashMap;
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::RwLock;
35
36/// One agent's registration for a capability.
37#[derive(Debug, Clone)]
38struct AgentEntry {
39    id: String,
40    /// Monotonic sequence assigned when the agent last successfully served
41    /// this capability. `None` for agents that haven't been invoked yet.
42    last_used: Option<u64>,
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    usage_sequence: AtomicU64,
54}
55
56impl AgentCapabilityRegistry {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Register `agent_id` as a provider for `capability`. Idempotent —
62    /// re-registering is a no-op (insertion order is preserved on the
63    /// first registration).
64    pub fn register(&self, capability: impl Into<String>, agent_id: impl Into<String>) {
65        let capability = capability.into();
66        let agent_id = agent_id.into();
67        let mut g = self.by_capability.write().expect("registry poisoned");
68        let entries = g.entry(capability).or_default();
69        if !entries.iter().any(|e| e.id == agent_id) {
70            entries.push(AgentEntry {
71                id: agent_id,
72                last_used: None,
73            });
74        }
75    }
76
77    /// Remove `agent_id` from `capability`. Used by the bundle uninstall
78    /// path (when it exists). Idempotent.
79    pub fn unregister(&self, capability: &str, agent_id: &str) {
80        let mut g = self.by_capability.write().expect("registry poisoned");
81        if let Some(entries) = g.get_mut(capability) {
82            entries.retain(|e| e.id != agent_id);
83            if entries.is_empty() {
84                g.remove(capability);
85            }
86        }
87    }
88
89    /// Snapshot of all agents registered for `capability`, in
90    /// registration order.
91    pub fn agents_for(&self, capability: &str) -> Vec<String> {
92        let g = self.by_capability.read().expect("registry poisoned");
93        g.get(capability)
94            .map(|v| v.iter().map(|e| e.id.clone()).collect())
95            .unwrap_or_default()
96    }
97
98    /// Snapshot of every agent registered for any capability,
99    /// deduplicated. Useful for the FFI surface's `list_agents(None)`
100    /// call.
101    pub fn all_agents(&self) -> Vec<String> {
102        let g = self.by_capability.read().expect("registry poisoned");
103        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
104        for entries in g.values() {
105            for e in entries {
106                seen.insert(e.id.clone());
107            }
108        }
109        seen.into_iter().collect()
110    }
111
112    /// Pick one agent for `capability`. Returns `None` only when no
113    /// agents are registered. See module docs for the selection
114    /// strategy.
115    pub fn select(&self, capability: &str, hint: Option<&str>) -> Option<String> {
116        let g = self.by_capability.read().expect("registry poisoned");
117        let entries = g.get(capability)?;
118        if entries.is_empty() {
119            return None;
120        }
121
122        // 1. Honor explicit hint if it matches a registered agent.
123        if let Some(h) = hint {
124            if let Some(e) = entries.iter().find(|e| e.id == h) {
125                return Some(e.id.clone());
126            }
127        }
128
129        // 2. Most-recently-used. We want: pick the agent with the
130        // newest `last_used`; if none have been used (or several tie
131        // at `None`), fall back to the first registered. `max_by_key`
132        // on an iterator returns the LAST max element when keys tie,
133        // which would invert insertion order — so we fold manually
134        // with strict-greater comparison.
135        let mut best: Option<&AgentEntry> = None;
136        for e in entries.iter() {
137            best = match best {
138                None => Some(e),
139                Some(b) if e.last_used > b.last_used => Some(e),
140                Some(b) => Some(b),
141            };
142        }
143        best.map(|e| e.id.clone())
144    }
145
146    /// Record that `agent_id` successfully served `capability`. Updates
147    /// the last-used timestamp for [`select`](crate::agent_capability::AgentCapabilityRegistry::select)'s MRU tiebreaker.
148    /// Silent no-op if the entry isn't registered.
149    pub fn note_used(&self, capability: &str, agent_id: &str) {
150        let mut g = self.by_capability.write().expect("registry poisoned");
151        if let Some(entries) = g.get_mut(capability) {
152            if let Some(e) = entries.iter_mut().find(|e| e.id == agent_id) {
153                e.last_used = Some(self.usage_sequence.fetch_add(1, Ordering::Relaxed) + 1);
154            }
155        }
156    }
157
158    /// Number of distinct capabilities tracked. For diagnostics.
159    pub fn capability_count(&self) -> usize {
160        self.by_capability.read().expect("registry poisoned").len()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn register_and_list() {
170        let r = AgentCapabilityRegistry::new();
171        r.register("summarize", "alpha");
172        r.register("summarize", "beta");
173        r.register("transcribe-audio", "alpha");
174        assert_eq!(r.agents_for("summarize"), vec!["alpha", "beta"]);
175        assert_eq!(r.agents_for("transcribe-audio"), vec!["alpha"]);
176        assert!(r.agents_for("nonexistent").is_empty());
177        assert_eq!(r.all_agents(), vec!["alpha", "beta"]);
178        assert_eq!(r.capability_count(), 2);
179    }
180
181    #[test]
182    fn register_is_idempotent() {
183        let r = AgentCapabilityRegistry::new();
184        r.register("summarize", "alpha");
185        r.register("summarize", "alpha");
186        r.register("summarize", "alpha");
187        assert_eq!(r.agents_for("summarize"), vec!["alpha"]);
188    }
189
190    #[test]
191    fn select_honors_hint() {
192        let r = AgentCapabilityRegistry::new();
193        r.register("summarize", "alpha");
194        r.register("summarize", "beta");
195        r.register("summarize", "gamma");
196        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
197        // Hint that doesn't match → fall through to MRU/insertion.
198        assert_eq!(
199            r.select("summarize", Some("nonexistent")),
200            Some("alpha".into())
201        );
202    }
203
204    #[test]
205    fn select_uses_mru_when_no_hint() {
206        let r = AgentCapabilityRegistry::new();
207        r.register("summarize", "alpha");
208        r.register("summarize", "beta");
209        // No usage yet → first registered (alpha).
210        assert_eq!(r.select("summarize", None), Some("alpha".into()));
211        // Touch beta → it becomes MRU.
212        r.note_used("summarize", "beta");
213        assert_eq!(r.select("summarize", None), Some("beta".into()));
214        // Touch alpha later → the monotonic usage sequence makes it win again.
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        r.note_used("summarize", "alpha");
259        assert_eq!(r.select("summarize", None), Some("alpha".into()));
260
261        // Hint still wins over MRU.
262        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
263    }
264}