use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scenario {
pub input: String,
pub expect: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DeclarativeGoal {
pub check: String,
#[serde(default = "default_goal_iterations")]
pub max_iterations: u32,
}
fn default_goal_iterations() -> u32 {
8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AgentCadenceTrigger {
Cron,
Interval,
Manual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentCadence {
pub trigger: AgentCadenceTrigger,
pub schedule: String,
#[serde(default)]
pub timezone: Option<String>,
pub phrase: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ContextPolicy {
#[default]
Car,
#[serde(rename = "self")]
SelfManaged,
}
impl ContextPolicy {
pub fn is_car_managed(self) -> bool {
match self {
ContextPolicy::Car => true,
ContextPolicy::SelfManaged => false,
}
}
pub fn as_str(self) -> &'static str {
match self {
ContextPolicy::Car => "car",
ContextPolicy::SelfManaged => "self",
}
}
}
impl<'de> Deserialize<'de> for ContextPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(deserializer)?;
match raw.as_str() {
Some("car") => Ok(ContextPolicy::Car),
Some("self") => Ok(ContextPolicy::SelfManaged),
_ => {
tracing::warn!(
value = %raw,
"declarative agent spec has an unrecognized `context` policy {raw}; \
expected \"car\" or \"self\" — using \"car\" (CAR manages the \
history). Fix the value in declagents.json to silence this."
);
Ok(ContextPolicy::Car)
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AgentBuilderDraft {
#[serde(default)]
pub template_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub responsibility: String,
#[serde(default)]
pub example: String,
#[serde(default)]
pub access: String,
#[serde(default)]
pub cadence: String,
#[serde(default)]
pub delivery: String,
#[serde(default)]
pub privacy: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeclarativeAgentSpec {
pub id: String,
pub name: String,
pub identity: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub denied_tools: Vec<String>,
#[serde(default)]
pub standing_goal: String,
#[serde(default)]
pub goal: Option<DeclarativeGoal>,
#[serde(default)]
pub cadence: Option<AgentCadence>,
#[serde(default)]
pub scenarios: Vec<Scenario>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builder_draft: Option<AgentBuilderDraft>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous: Option<Box<DeclarativeAgentSpec>>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub context: ContextPolicy,
}
fn default_true() -> bool {
true
}
impl DeclarativeAgentSpec {
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());
}
}
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 == '.')
}
pub struct DeclRegistry {
path: PathBuf,
}
impl DeclRegistry {
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 root = car_home::root()
.ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
Ok(Self {
path: root.join("declagents.json"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
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())?;
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()))
}
pub fn upsert(&self, mut 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) {
let mut previous = existing.clone();
previous.previous = None;
spec.previous = Some(Box::new(previous));
*existing = spec;
} else {
spec.previous = None;
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,
cadence: None,
scenarios: vec![Scenario {
input: "hi".into(),
expect: "ok".into(),
}],
builder_draft: None,
previous: None,
enabled: true,
context: ContextPolicy::default(),
}
}
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");
let mut updated = spec("email-bot");
updated.name = "Renamed".into();
reg.upsert(updated).unwrap();
assert_eq!(reg.list().len(), 2);
let current = reg.get("email-bot").unwrap();
assert_eq!(current.name, "Renamed");
assert_eq!(current.previous.as_deref().unwrap().name, "Test");
assert!(current.previous.as_deref().unwrap().previous.is_none());
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 get_preserves_existing_registry_bytes_and_path_is_derived() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("declagents.json");
let fixture = r#"[
{
"id": "existing-agent",
"name": "Existing",
"identity": "You preserve old state.",
"tools": [],
"standing_goal": "remain compatible",
"scenarios": [{"input": "ping", "expect": "pong"}],
"enabled": true
}
]
"#;
std::fs::write(&path, fixture).unwrap();
let reg = DeclRegistry::at(&path);
assert_eq!(reg.path(), path.as_path());
assert_eq!(reg.get("existing-agent").unwrap().name, "Existing");
assert_eq!(
std::fs::read(&path).unwrap(),
fixture.as_bytes(),
"a metadata read must not write registry_path into persisted user state"
);
}
#[test]
fn persists_across_handles() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("declagents.json");
DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
assert!(DeclRegistry::at(&path).get("persist").is_some());
}
#[test]
fn cadence_round_trips_and_is_optional_for_existing_specs() {
let mut s = spec("cadence");
s.cadence = Some(AgentCadence {
trigger: AgentCadenceTrigger::Cron,
schedule: "30 8 * * 1-5".into(),
timezone: Some("America/New_York".into()),
phrase: "Weekdays at 8:30".into(),
});
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["cadence"]["trigger"], "cron");
assert_eq!(json["cadence"]["schedule"], "30 8 * * 1-5");
assert_eq!(json["cadence"]["timezone"], "America/New_York");
assert_eq!(json["cadence"]["phrase"], "Weekdays at 8:30");
assert_eq!(
serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
s
);
let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
"id": "legacy",
"name": "Legacy",
"identity": "You predate cadence.",
"scenarios": [{"input": "ping", "expect": "pong"}],
}))
.unwrap();
assert_eq!(legacy.cadence, None);
}
#[test]
fn builder_draft_and_previous_round_trip_with_one_level_of_history() {
let (_d, reg) = temp_registry();
let mut first = spec("editable");
first.builder_draft = Some(AgentBuilderDraft {
template_id: "researchBrief".into(),
name: "Research Brief".into(),
responsibility: "Research assigned questions".into(),
example: "Compare three options".into(),
access: "Public web".into(),
cadence: "When assigned".into(),
delivery: "Save in Work".into(),
privacy: "Keep supplied files local".into(),
});
reg.upsert(first.clone()).unwrap();
let mut second = first;
second.standing_goal = "Deliver cited research".into();
second.builder_draft.as_mut().unwrap().cadence = "Every weekday".into();
second.previous = Some(Box::new(spec("ignored-history")));
reg.upsert(second).unwrap();
let loaded = DeclRegistry::at(reg.path()).get("editable").unwrap();
assert_eq!(
loaded.builder_draft.as_ref().unwrap().cadence,
"Every weekday"
);
let previous = loaded.previous.as_deref().unwrap();
assert_eq!(
previous.builder_draft.as_ref().unwrap().cadence,
"When assigned"
);
assert!(previous.previous.is_none());
let json = serde_json::to_string(&loaded).unwrap();
let round_tripped: DeclarativeAgentSpec = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped, loaded);
}
#[test]
fn legacy_spec_defaults_builder_history_to_none() {
let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
"id": "legacy",
"name": "Legacy",
"identity": "You predate editing.",
"scenarios": [{"input": "ping", "expect": "pong"}]
}))
.unwrap();
assert!(legacy.builder_draft.is_none());
assert!(legacy.previous.is_none());
}
#[test]
fn context_policy_round_trips_through_the_spec() {
let mut s = spec("ctx");
assert_eq!(s.context, ContextPolicy::Car, "default is CAR-managed");
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["context"], "car");
assert_eq!(
serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
s
);
s.context = ContextPolicy::SelfManaged;
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["context"], "self");
assert_eq!(
serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
s
);
}
#[test]
fn a_spec_written_before_the_context_field_still_loads_as_car_managed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("declagents.json");
std::fs::write(
&path,
r#"[
{
"id": "legacy-agent",
"name": "Legacy",
"identity": "You predate the context field.",
"tools": [],
"standing_goal": "remain compatible",
"scenarios": [{"input": "ping", "expect": "pong"}],
"enabled": true
}
]
"#,
)
.unwrap();
let loaded = DeclRegistry::at(&path).get("legacy-agent").unwrap();
assert_eq!(loaded.context, ContextPolicy::Car);
assert!(loaded.context.is_car_managed());
}
#[test]
fn a_mistyped_context_value_warns_and_defaults_instead_of_failing_the_parse() {
let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
"id": "typo",
"name": "Typo",
"identity": "x",
"scenarios": [{"input": "a", "expect": "b"}],
"context": "mine",
}))
.expect("a mistyped context policy must still load");
assert_eq!(spec.context, ContextPolicy::Car);
for bad in [
serde_json::json!(5),
serde_json::json!(null),
serde_json::json!({"who": "me"}),
serde_json::json!("Self"),
] {
let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
"id": "typo",
"name": "Typo",
"identity": "x",
"scenarios": [{"input": "a", "expect": "b"}],
"context": bad,
}))
.expect("a malformed context policy must still load");
assert_eq!(spec.context, ContextPolicy::Car);
}
}
#[test]
fn a_mistyped_context_value_cannot_empty_the_registry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("declagents.json");
std::fs::write(
&path,
r#"[
{
"id": "good-agent",
"name": "Good",
"identity": "You are fine.",
"scenarios": [{"input": "ping", "expect": "pong"}],
"enabled": true,
"context": "self"
},
{
"id": "typo-agent",
"name": "Typo",
"identity": "You were hand-edited.",
"scenarios": [{"input": "ping", "expect": "pong"}],
"enabled": true,
"context": "mine"
}
]
"#,
)
.unwrap();
let reg = DeclRegistry::at(&path);
assert_eq!(reg.list().len(), 2, "a typo must not empty the registry");
assert_eq!(
reg.get("typo-agent").unwrap().context,
ContextPolicy::Car,
"the mistyped policy falls back to the managed default"
);
assert_eq!(
reg.get("good-agent").unwrap().context,
ContextPolicy::SelfManaged,
"a valid neighbour is unaffected"
);
reg.upsert(spec("third")).unwrap();
assert_eq!(
DeclRegistry::at(&path).list().len(),
3,
"upsert after a tolerated typo must not have written an empty registry"
);
}
}