car-registry 0.47.0

File-based agent registry + lifecycle supervisor for Common Agent Runtime.
Documentation
//! Declarative agents — in-daemon agents that need no external process.
//!
//! A [`DeclarativeAgentSpec`] is pure data: an identity (system prompt), a
//! tool **allowlist** (names of tools the daemon already exposes), an optional
//! deny list, a standing goal, an optional deterministic completion goal, and
//! scenarios (test cases). The daemon runs it with a generic model→tool loop — there is no command to spawn, so a
//! non-developer never installs Node/Python/anything.
//!
//! This is a **parallel registry** to [`crate::supervisor`], deliberately NOT
//! an extension of `supervisor::AgentSpec`: that type's whole security model is
//! the absolute-executable-path validation in `Supervisor::upsert` (the
//! 2026-05 audit's control). A declarative agent has no command, so it must
//! never travel through that path. `agents.list` read-merges declarative
//! entries (tagged `kind:"declarative"`) for the unified host view.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// A test case for a declarative agent: run it on `input`, the output must
/// contain `expect` (a stable substring — not an exact match, so the contract
/// tolerates benign model variation).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scenario {
    pub input: String,
    pub expect: String,
}

/// Deterministic completion contract for a declarative agent invocation.
/// The daemon runs `check` in the same scratch worktree after each agent pass
/// and re-drives until it exits 0 or `max_iterations` is exhausted.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeclarativeGoal {
    pub check: String,
    #[serde(default = "default_goal_iterations")]
    pub max_iterations: u32,
}

fn default_goal_iterations() -> u32 {
    8
}

/// A declarative, in-daemon agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeclarativeAgentSpec {
    /// Filename-safe id (derived from the project slug).
    pub id: String,
    pub name: String,
    /// System prompt — who the agent is and how it behaves.
    pub identity: String,
    /// Allowlist of tool names the agent may call. The daemon exposes ONLY
    /// these to the model; anything else is invisible. Empty = no tools
    /// (a pure-reasoning agent).
    #[serde(default)]
    pub tools: Vec<String>,
    /// Extra hard denials layered under the allowlist (belt and suspenders).
    #[serde(default)]
    pub denied_tools: Vec<String>,
    /// The agent's persistent objective, prepended to every run.
    #[serde(default)]
    pub standing_goal: String,
    /// Optional deterministic completion contract. This is not shown to the
    /// model as a tool; it is the runtime-owned verifier for each invocation.
    #[serde(default)]
    pub goal: Option<DeclarativeGoal>,
    /// Acceptance scenarios — the contract the coder→agent loop drives to green.
    #[serde(default)]
    pub scenarios: Vec<Scenario>,
    /// Lifecycle toggle. A disabled agent stays registered but won't run.
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_true() -> bool {
    true
}

impl DeclarativeAgentSpec {
    /// Structural problems that make a spec unusable. Empty = valid.
    pub fn validate(&self) -> Vec<String> {
        let mut issues = Vec::new();
        if !is_filename_safe(&self.id) {
            issues.push(format!(
                "invalid agent id (alphanumeric + -_.): {:?}",
                self.id
            ));
        }
        if self.name.trim().is_empty() {
            issues.push("agent name is empty".into());
        }
        if self.identity.trim().is_empty() {
            issues.push("agent identity (system prompt) is empty".into());
        }
        if let Some(goal) = &self.goal {
            if goal.check.trim().is_empty() {
                issues.push("agent goal.check is empty".into());
            }
            if goal.max_iterations == 0 || goal.max_iterations > 50 {
                issues.push("agent goal.max_iterations must be between 1 and 50".into());
            }
        }
        // Scenarios are the contract the coder→agent loop drives to green; an
        // agent with none was never validated against any example. The builder
        // always writes at least one, so a zero-scenario spec is a hand-edit or
        // a malformed write — reject it at upsert rather than register an
        // unverifiable agent.
        if self.scenarios.is_empty() {
            issues.push("agent must have at least one acceptance scenario".into());
        }
        issues
    }
}

fn is_filename_safe(id: &str) -> bool {
    !id.is_empty()
        && id != "."
        && id != ".."
        && id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}

/// File-backed registry of declarative agents at `~/.car/declagents.json`
/// (`CAR_DECLAGENTS_PATH` overrides for tests/embedders). Atomic write-through
/// — the on-disk JSON is authoritative across restarts, mirroring the
/// supervisor manifest's hygiene.
pub struct DeclRegistry {
    path: PathBuf,
}

impl DeclRegistry {
    /// `~/.car/declagents.json` (or `CAR_DECLAGENTS_PATH`).
    pub fn user_default() -> Result<Self, String> {
        if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
            return Ok(Self {
                path: PathBuf::from(p),
            });
        }
        let home = std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
        Ok(Self {
            path: PathBuf::from(home).join(".car").join("declagents.json"),
        })
    }

    pub fn at(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
        std::fs::read_to_string(&self.path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("create {}: {e}", parent.display()))?;
        }
        let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
        // Atomic: write a sibling temp then rename over the target.
        let tmp = self.path.with_extension("json.tmp");
        std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
        std::fs::rename(&tmp, &self.path)
            .map_err(|e| format!("rename into {}: {e}", self.path.display()))
    }

    /// Insert or replace by id. Validates the spec first.
    pub fn upsert(&self, spec: DeclarativeAgentSpec) -> Result<(), String> {
        let issues = spec.validate();
        if !issues.is_empty() {
            return Err(format!("invalid declarative agent: {}", issues.join("; ")));
        }
        let mut all = self.read_all();
        if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
            *existing = spec;
        } else {
            all.push(spec);
        }
        self.write_all(&all)
    }

    pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
        self.read_all()
    }

    pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
        self.read_all().into_iter().find(|s| s.id == id)
    }

    pub fn remove(&self, id: &str) -> Result<bool, String> {
        let mut all = self.read_all();
        let before = all.len();
        all.retain(|s| s.id != id);
        let removed = all.len() != before;
        if removed {
            self.write_all(&all)?;
        }
        Ok(removed)
    }

    pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
        let mut all = self.read_all();
        let spec = all
            .iter_mut()
            .find(|s| s.id == id)
            .ok_or_else(|| format!("no declarative agent '{id}'"))?;
        spec.enabled = on;
        self.write_all(&all)
    }
}

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

    fn spec(id: &str) -> DeclarativeAgentSpec {
        DeclarativeAgentSpec {
            id: id.into(),
            name: "Test".into(),
            identity: "You are a test agent.".into(),
            tools: vec!["read_file".into()],
            denied_tools: vec![],
            standing_goal: "be helpful".into(),
            goal: None,
            scenarios: vec![Scenario {
                input: "hi".into(),
                expect: "ok".into(),
            }],
            enabled: true,
        }
    }

    fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
        let dir = tempfile::tempdir().unwrap();
        let reg = DeclRegistry::at(dir.path().join("declagents.json"));
        (dir, reg)
    }

    #[test]
    fn upsert_rejects_spec_with_no_scenarios() {
        let (_d, reg) = temp_registry();
        let mut s = spec("no-scenarios");
        s.scenarios.clear();
        let err = reg
            .upsert(s)
            .expect_err("zero-scenario spec must be rejected");
        assert!(
            err.contains("at least one acceptance scenario"),
            "got: {err}"
        );
    }

    #[test]
    fn upsert_rejects_invalid_goal_contract() {
        let (_d, reg) = temp_registry();
        let mut s = spec("bad-goal");
        s.goal = Some(DeclarativeGoal {
            check: " ".into(),
            max_iterations: 0,
        });
        let err = reg.upsert(s).expect_err("invalid goal must be rejected");
        assert!(err.contains("goal.check"), "{err}");
        assert!(err.contains("goal.max_iterations"), "{err}");
    }

    #[test]
    fn upsert_list_get_remove_round_trip() {
        let (_d, reg) = temp_registry();
        reg.upsert(spec("email-bot")).unwrap();
        reg.upsert(spec("note-taker")).unwrap();
        assert_eq!(reg.list().len(), 2);
        assert_eq!(reg.get("email-bot").unwrap().name, "Test");
        // Upsert replaces, doesn't duplicate.
        let mut updated = spec("email-bot");
        updated.name = "Renamed".into();
        reg.upsert(updated).unwrap();
        assert_eq!(reg.list().len(), 2);
        assert_eq!(reg.get("email-bot").unwrap().name, "Renamed");
        assert!(reg.remove("email-bot").unwrap());
        assert!(!reg.remove("email-bot").unwrap());
        assert_eq!(reg.list().len(), 1);
    }

    #[test]
    fn set_enabled_toggles() {
        let (_d, reg) = temp_registry();
        reg.upsert(spec("a")).unwrap();
        reg.set_enabled("a", false).unwrap();
        assert!(!reg.get("a").unwrap().enabled);
        reg.set_enabled("a", true).unwrap();
        assert!(reg.get("a").unwrap().enabled);
        assert!(reg.set_enabled("missing", true).is_err());
    }

    #[test]
    fn invalid_spec_is_rejected() {
        let (_d, reg) = temp_registry();
        let mut bad = spec("../escape");
        assert!(reg.upsert(bad.clone()).is_err());
        bad.id = "ok".into();
        bad.identity = "  ".into();
        assert!(reg.upsert(bad).is_err());
    }

    #[test]
    fn persists_across_handles() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("declagents.json");
        DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
        // A fresh handle reads the same on-disk state.
        assert!(DeclRegistry::at(&path).get("persist").is_some());
    }
}