car-engine 0.14.0

Core runtime engine for Common Agent Runtime
Documentation
//! Agent capability registry — maps capability id → agents that implement it.
//!
//! Differs from sibling [`crate::capabilities`] (which restricts what tools
//! an agent may call). This registry answers the inverse question: given
//! a capability id from the agent-bundle vocabulary
//! (`docs/agent-bundle-spec.md` — `transcribe-audio`, `summarize`, etc.),
//! which installed agents implement it?
//!
//! Used by `invoke_capability` dispatch (Rust side) and by the host app's
//! `DynamicOptionsProvider` (Swift side, via UniFFI's `list_agents`).
//!
//! ## Selection strategy
//!
//! When multiple agents advertise the same capability, [`select`] picks
//! one in this order:
//!
//! 1. The caller-provided `hint` if it names a registered agent.
//! 2. Most-recently-used agent for the capability.
//! 3. First registered (insertion order).
//!
//! This is intentionally simple. A scoring-based selector (latency,
//! quality, cost) belongs in `car-inference`'s adaptive router, not
//! here — this layer only handles "which agent" once "which model"
//! has already been resolved upstream.
//!
//! ## Persistence
//!
//! In-memory only for v1. The bundle install path (when it lands) will
//! re-register on each runtime start. Last-used timestamps don't
//! survive restart — pinning by hint is the durable signal.

use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Instant;

/// One agent's registration for a capability.
#[derive(Debug, Clone)]
struct AgentEntry {
    id: String,
    /// When the agent last successfully served this capability. `None`
    /// for newly-registered agents that haven't been invoked yet.
    last_used: Option<Instant>,
}

/// Maps capability id → ordered list of agents that implement it.
///
/// Concurrent access via `RwLock`. Reads (the hot path during
/// dispatch) are non-blocking under contention; writes (registration,
/// usage tracking) are infrequent.
#[derive(Debug, Default)]
pub struct AgentCapabilityRegistry {
    by_capability: RwLock<HashMap<String, Vec<AgentEntry>>>,
}

impl AgentCapabilityRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register `agent_id` as a provider for `capability`. Idempotent —
    /// re-registering is a no-op (insertion order is preserved on the
    /// first registration).
    pub fn register(&self, capability: impl Into<String>, agent_id: impl Into<String>) {
        let capability = capability.into();
        let agent_id = agent_id.into();
        let mut g = self.by_capability.write().expect("registry poisoned");
        let entries = g.entry(capability).or_default();
        if !entries.iter().any(|e| e.id == agent_id) {
            entries.push(AgentEntry {
                id: agent_id,
                last_used: None,
            });
        }
    }

    /// Remove `agent_id` from `capability`. Used by the bundle uninstall
    /// path (when it exists). Idempotent.
    pub fn unregister(&self, capability: &str, agent_id: &str) {
        let mut g = self.by_capability.write().expect("registry poisoned");
        if let Some(entries) = g.get_mut(capability) {
            entries.retain(|e| e.id != agent_id);
            if entries.is_empty() {
                g.remove(capability);
            }
        }
    }

    /// Snapshot of all agents registered for `capability`, in
    /// registration order.
    pub fn agents_for(&self, capability: &str) -> Vec<String> {
        let g = self.by_capability.read().expect("registry poisoned");
        g.get(capability)
            .map(|v| v.iter().map(|e| e.id.clone()).collect())
            .unwrap_or_default()
    }

    /// Snapshot of every agent registered for any capability,
    /// deduplicated. Useful for the FFI surface's `list_agents(None)`
    /// call.
    pub fn all_agents(&self) -> Vec<String> {
        let g = self.by_capability.read().expect("registry poisoned");
        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
        for entries in g.values() {
            for e in entries {
                seen.insert(e.id.clone());
            }
        }
        seen.into_iter().collect()
    }

    /// Pick one agent for `capability`. Returns `None` only when no
    /// agents are registered. See module docs for the selection
    /// strategy.
    pub fn select(&self, capability: &str, hint: Option<&str>) -> Option<String> {
        let g = self.by_capability.read().expect("registry poisoned");
        let entries = g.get(capability)?;
        if entries.is_empty() {
            return None;
        }

        // 1. Honor explicit hint if it matches a registered agent.
        if let Some(h) = hint {
            if let Some(e) = entries.iter().find(|e| e.id == h) {
                return Some(e.id.clone());
            }
        }

        // 2. Most-recently-used. We want: pick the agent with the
        // newest `last_used`; if none have been used (or several tie
        // at `None`), fall back to the first registered. `max_by_key`
        // on an iterator returns the LAST max element when keys tie,
        // which would invert insertion order — so we fold manually
        // with strict-greater comparison.
        let mut best: Option<&AgentEntry> = None;
        for e in entries.iter() {
            best = match best {
                None => Some(e),
                Some(b) if e.last_used > b.last_used => Some(e),
                Some(b) => Some(b),
            };
        }
        best.map(|e| e.id.clone())
    }

    /// Record that `agent_id` successfully served `capability`. Updates
    /// the last-used timestamp for [`select`]'s MRU tiebreaker.
    /// Silent no-op if the entry isn't registered.
    pub fn note_used(&self, capability: &str, agent_id: &str) {
        let mut g = self.by_capability.write().expect("registry poisoned");
        if let Some(entries) = g.get_mut(capability) {
            if let Some(e) = entries.iter_mut().find(|e| e.id == agent_id) {
                e.last_used = Some(Instant::now());
            }
        }
    }

    /// Number of distinct capabilities tracked. For diagnostics.
    pub fn capability_count(&self) -> usize {
        self.by_capability.read().expect("registry poisoned").len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn register_and_list() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "beta");
        r.register("transcribe-audio", "alpha");
        assert_eq!(r.agents_for("summarize"), vec!["alpha", "beta"]);
        assert_eq!(r.agents_for("transcribe-audio"), vec!["alpha"]);
        assert!(r.agents_for("nonexistent").is_empty());
        assert_eq!(r.all_agents(), vec!["alpha", "beta"]);
        assert_eq!(r.capability_count(), 2);
    }

    #[test]
    fn register_is_idempotent() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "alpha");
        r.register("summarize", "alpha");
        assert_eq!(r.agents_for("summarize"), vec!["alpha"]);
    }

    #[test]
    fn select_honors_hint() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "beta");
        r.register("summarize", "gamma");
        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
        // Hint that doesn't match → fall through to MRU/insertion.
        assert_eq!(
            r.select("summarize", Some("nonexistent")),
            Some("alpha".into())
        );
    }

    #[test]
    fn select_uses_mru_when_no_hint() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "beta");
        // No usage yet → first registered (alpha).
        assert_eq!(r.select("summarize", None), Some("alpha".into()));
        // Touch beta → it becomes MRU.
        r.note_used("summarize", "beta");
        assert_eq!(r.select("summarize", None), Some("beta".into()));
        // Touch alpha later → it wins again.
        std::thread::sleep(std::time::Duration::from_millis(2));
        r.note_used("summarize", "alpha");
        assert_eq!(r.select("summarize", None), Some("alpha".into()));
    }

    #[test]
    fn unregister_removes() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "beta");
        r.unregister("summarize", "alpha");
        assert_eq!(r.agents_for("summarize"), vec!["beta"]);
        r.unregister("summarize", "beta");
        assert!(r.agents_for("summarize").is_empty());
        assert_eq!(r.capability_count(), 0);
    }

    #[test]
    fn select_returns_none_for_unknown_capability() {
        let r = AgentCapabilityRegistry::new();
        assert_eq!(r.select("nope", None), None);
    }

    /// Three-agent tiebreak: with one agent touched and two at
    /// `last_used = None`, the touched one wins; with all three
    /// untouched, first-registered wins; touching a different one
    /// flips MRU. Locks the strict-greater fold semantics —
    /// without it the impl could regress to `max_by_key` and
    /// silently invert insertion order on ties.
    #[test]
    fn select_three_agent_tiebreak() {
        let r = AgentCapabilityRegistry::new();
        r.register("summarize", "alpha");
        r.register("summarize", "beta");
        r.register("summarize", "gamma");

        // All untouched → first-registered wins.
        assert_eq!(r.select("summarize", None), Some("alpha".into()));

        // Touch gamma → gamma wins; alpha and beta still tied at None.
        r.note_used("summarize", "gamma");
        assert_eq!(r.select("summarize", None), Some("gamma".into()));

        // Touch alpha later → alpha wins now.
        std::thread::sleep(std::time::Duration::from_millis(2));
        r.note_used("summarize", "alpha");
        assert_eq!(r.select("summarize", None), Some("alpha".into()));

        // Hint still wins over MRU.
        assert_eq!(r.select("summarize", Some("beta")), Some("beta".into()));
    }
}