1use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Scenario {
24 pub input: String,
25 pub expect: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct DeclarativeGoal {
33 pub check: String,
34 #[serde(default = "default_goal_iterations")]
35 pub max_iterations: u32,
36}
37
38fn default_goal_iterations() -> u32 {
39 8
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
56#[serde(rename_all = "lowercase")]
57pub enum ContextPolicy {
58 #[default]
65 Car,
66 #[serde(rename = "self")]
70 SelfManaged,
71}
72
73impl ContextPolicy {
74 pub fn is_car_managed(self) -> bool {
81 match self {
82 ContextPolicy::Car => true,
83 ContextPolicy::SelfManaged => false,
84 }
85 }
86
87 pub fn as_str(self) -> &'static str {
89 match self {
90 ContextPolicy::Car => "car",
91 ContextPolicy::SelfManaged => "self",
92 }
93 }
94}
95
96impl<'de> Deserialize<'de> for ContextPolicy {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113 where
114 D: serde::Deserializer<'de>,
115 {
116 let raw = serde_json::Value::deserialize(deserializer)?;
120 match raw.as_str() {
121 Some("car") => Ok(ContextPolicy::Car),
122 Some("self") => Ok(ContextPolicy::SelfManaged),
123 _ => {
124 tracing::warn!(
125 value = %raw,
126 "declarative agent spec has an unrecognized `context` policy {raw}; \
127 expected \"car\" or \"self\" — using \"car\" (CAR manages the \
128 history). Fix the value in declagents.json to silence this."
129 );
130 Ok(ContextPolicy::Car)
131 }
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct DeclarativeAgentSpec {
139 pub id: String,
141 pub name: String,
142 pub identity: String,
144 #[serde(default)]
148 pub tools: Vec<String>,
149 #[serde(default)]
151 pub denied_tools: Vec<String>,
152 #[serde(default)]
154 pub standing_goal: String,
155 #[serde(default)]
158 pub goal: Option<DeclarativeGoal>,
159 #[serde(default)]
161 pub scenarios: Vec<Scenario>,
162 #[serde(default = "default_true")]
164 pub enabled: bool,
165 #[serde(default)]
172 pub context: ContextPolicy,
173}
174
175fn default_true() -> bool {
176 true
177}
178
179impl DeclarativeAgentSpec {
180 pub fn validate(&self) -> Vec<String> {
182 let mut issues = Vec::new();
183 if !is_filename_safe(&self.id) {
184 issues.push(format!(
185 "invalid agent id (alphanumeric + -_.): {:?}",
186 self.id
187 ));
188 }
189 if self.name.trim().is_empty() {
190 issues.push("agent name is empty".into());
191 }
192 if self.identity.trim().is_empty() {
193 issues.push("agent identity (system prompt) is empty".into());
194 }
195 if let Some(goal) = &self.goal {
196 if goal.check.trim().is_empty() {
197 issues.push("agent goal.check is empty".into());
198 }
199 if goal.max_iterations == 0 || goal.max_iterations > 50 {
200 issues.push("agent goal.max_iterations must be between 1 and 50".into());
201 }
202 }
203 if self.scenarios.is_empty() {
209 issues.push("agent must have at least one acceptance scenario".into());
210 }
211 issues
212 }
213}
214
215fn is_filename_safe(id: &str) -> bool {
216 !id.is_empty()
217 && id != "."
218 && id != ".."
219 && id
220 .chars()
221 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
222}
223
224pub struct DeclRegistry {
229 path: PathBuf,
230}
231
232impl DeclRegistry {
233 pub fn user_default() -> Result<Self, String> {
239 if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
240 return Ok(Self {
241 path: PathBuf::from(p),
242 });
243 }
244 let root = car_home::root()
245 .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
246 Ok(Self {
247 path: root.join("declagents.json"),
248 })
249 }
250
251 pub fn at(path: impl Into<PathBuf>) -> Self {
252 Self { path: path.into() }
253 }
254
255 pub fn path(&self) -> &Path {
260 &self.path
261 }
262
263 fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
264 std::fs::read_to_string(&self.path)
265 .ok()
266 .and_then(|s| serde_json::from_str(&s).ok())
267 .unwrap_or_default()
268 }
269
270 fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
271 if let Some(parent) = self.path.parent() {
272 std::fs::create_dir_all(parent)
273 .map_err(|e| format!("create {}: {e}", parent.display()))?;
274 }
275 let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
276 let tmp = self.path.with_extension("json.tmp");
278 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
279 std::fs::rename(&tmp, &self.path)
280 .map_err(|e| format!("rename into {}: {e}", self.path.display()))
281 }
282
283 pub fn upsert(&self, spec: DeclarativeAgentSpec) -> Result<(), String> {
285 let issues = spec.validate();
286 if !issues.is_empty() {
287 return Err(format!("invalid declarative agent: {}", issues.join("; ")));
288 }
289 let mut all = self.read_all();
290 if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
291 *existing = spec;
292 } else {
293 all.push(spec);
294 }
295 self.write_all(&all)
296 }
297
298 pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
299 self.read_all()
300 }
301
302 pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
303 self.read_all().into_iter().find(|s| s.id == id)
304 }
305
306 pub fn remove(&self, id: &str) -> Result<bool, String> {
307 let mut all = self.read_all();
308 let before = all.len();
309 all.retain(|s| s.id != id);
310 let removed = all.len() != before;
311 if removed {
312 self.write_all(&all)?;
313 }
314 Ok(removed)
315 }
316
317 pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
318 let mut all = self.read_all();
319 let spec = all
320 .iter_mut()
321 .find(|s| s.id == id)
322 .ok_or_else(|| format!("no declarative agent '{id}'"))?;
323 spec.enabled = on;
324 self.write_all(&all)
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn spec(id: &str) -> DeclarativeAgentSpec {
333 DeclarativeAgentSpec {
334 id: id.into(),
335 name: "Test".into(),
336 identity: "You are a test agent.".into(),
337 tools: vec!["read_file".into()],
338 denied_tools: vec![],
339 standing_goal: "be helpful".into(),
340 goal: None,
341 scenarios: vec![Scenario {
342 input: "hi".into(),
343 expect: "ok".into(),
344 }],
345 enabled: true,
346 context: ContextPolicy::default(),
347 }
348 }
349
350 fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
351 let dir = tempfile::tempdir().unwrap();
352 let reg = DeclRegistry::at(dir.path().join("declagents.json"));
353 (dir, reg)
354 }
355
356 #[test]
357 fn upsert_rejects_spec_with_no_scenarios() {
358 let (_d, reg) = temp_registry();
359 let mut s = spec("no-scenarios");
360 s.scenarios.clear();
361 let err = reg
362 .upsert(s)
363 .expect_err("zero-scenario spec must be rejected");
364 assert!(
365 err.contains("at least one acceptance scenario"),
366 "got: {err}"
367 );
368 }
369
370 #[test]
371 fn upsert_rejects_invalid_goal_contract() {
372 let (_d, reg) = temp_registry();
373 let mut s = spec("bad-goal");
374 s.goal = Some(DeclarativeGoal {
375 check: " ".into(),
376 max_iterations: 0,
377 });
378 let err = reg.upsert(s).expect_err("invalid goal must be rejected");
379 assert!(err.contains("goal.check"), "{err}");
380 assert!(err.contains("goal.max_iterations"), "{err}");
381 }
382
383 #[test]
384 fn upsert_list_get_remove_round_trip() {
385 let (_d, reg) = temp_registry();
386 reg.upsert(spec("email-bot")).unwrap();
387 reg.upsert(spec("note-taker")).unwrap();
388 assert_eq!(reg.list().len(), 2);
389 assert_eq!(reg.get("email-bot").unwrap().name, "Test");
390 let mut updated = spec("email-bot");
392 updated.name = "Renamed".into();
393 reg.upsert(updated).unwrap();
394 assert_eq!(reg.list().len(), 2);
395 assert_eq!(reg.get("email-bot").unwrap().name, "Renamed");
396 assert!(reg.remove("email-bot").unwrap());
397 assert!(!reg.remove("email-bot").unwrap());
398 assert_eq!(reg.list().len(), 1);
399 }
400
401 #[test]
402 fn set_enabled_toggles() {
403 let (_d, reg) = temp_registry();
404 reg.upsert(spec("a")).unwrap();
405 reg.set_enabled("a", false).unwrap();
406 assert!(!reg.get("a").unwrap().enabled);
407 reg.set_enabled("a", true).unwrap();
408 assert!(reg.get("a").unwrap().enabled);
409 assert!(reg.set_enabled("missing", true).is_err());
410 }
411
412 #[test]
413 fn invalid_spec_is_rejected() {
414 let (_d, reg) = temp_registry();
415 let mut bad = spec("../escape");
416 assert!(reg.upsert(bad.clone()).is_err());
417 bad.id = "ok".into();
418 bad.identity = " ".into();
419 assert!(reg.upsert(bad).is_err());
420 }
421
422 #[test]
423 fn get_preserves_existing_registry_bytes_and_path_is_derived() {
424 let dir = tempfile::tempdir().unwrap();
425 let path = dir.path().join("declagents.json");
426 let fixture = r#"[
427 {
428 "id": "existing-agent",
429 "name": "Existing",
430 "identity": "You preserve old state.",
431 "tools": [],
432 "standing_goal": "remain compatible",
433 "scenarios": [{"input": "ping", "expect": "pong"}],
434 "enabled": true
435 }
436]
437"#;
438 std::fs::write(&path, fixture).unwrap();
439 let reg = DeclRegistry::at(&path);
440
441 assert_eq!(reg.path(), path.as_path());
442 assert_eq!(reg.get("existing-agent").unwrap().name, "Existing");
443 assert_eq!(
444 std::fs::read(&path).unwrap(),
445 fixture.as_bytes(),
446 "a metadata read must not write registry_path into persisted user state"
447 );
448 }
449
450 #[test]
451 fn persists_across_handles() {
452 let dir = tempfile::tempdir().unwrap();
453 let path = dir.path().join("declagents.json");
454 DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
455 assert!(DeclRegistry::at(&path).get("persist").is_some());
457 }
458
459 #[test]
460 fn context_policy_round_trips_through_the_spec() {
461 let mut s = spec("ctx");
463 assert_eq!(s.context, ContextPolicy::Car, "default is CAR-managed");
464 let json = serde_json::to_value(&s).unwrap();
465 assert_eq!(json["context"], "car");
466 assert_eq!(
467 serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
468 s
469 );
470
471 s.context = ContextPolicy::SelfManaged;
472 let json = serde_json::to_value(&s).unwrap();
473 assert_eq!(json["context"], "self");
474 assert_eq!(
475 serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
476 s
477 );
478 }
479
480 #[test]
481 fn a_spec_written_before_the_context_field_still_loads_as_car_managed() {
482 let dir = tempfile::tempdir().unwrap();
485 let path = dir.path().join("declagents.json");
486 std::fs::write(
487 &path,
488 r#"[
489 {
490 "id": "legacy-agent",
491 "name": "Legacy",
492 "identity": "You predate the context field.",
493 "tools": [],
494 "standing_goal": "remain compatible",
495 "scenarios": [{"input": "ping", "expect": "pong"}],
496 "enabled": true
497 }
498]
499"#,
500 )
501 .unwrap();
502
503 let loaded = DeclRegistry::at(&path).get("legacy-agent").unwrap();
504 assert_eq!(loaded.context, ContextPolicy::Car);
505 assert!(loaded.context.is_car_managed());
506 }
507
508 #[test]
509 fn a_mistyped_context_value_warns_and_defaults_instead_of_failing_the_parse() {
510 let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
516 "id": "typo",
517 "name": "Typo",
518 "identity": "x",
519 "scenarios": [{"input": "a", "expect": "b"}],
520 "context": "mine",
521 }))
522 .expect("a mistyped context policy must still load");
523 assert_eq!(spec.context, ContextPolicy::Car);
524
525 for bad in [
528 serde_json::json!(5),
529 serde_json::json!(null),
530 serde_json::json!({"who": "me"}),
531 serde_json::json!("Self"),
532 ] {
533 let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
534 "id": "typo",
535 "name": "Typo",
536 "identity": "x",
537 "scenarios": [{"input": "a", "expect": "b"}],
538 "context": bad,
539 }))
540 .expect("a malformed context policy must still load");
541 assert_eq!(spec.context, ContextPolicy::Car);
542 }
543 }
544
545 #[test]
546 fn a_mistyped_context_value_cannot_empty_the_registry() {
547 let dir = tempfile::tempdir().unwrap();
551 let path = dir.path().join("declagents.json");
552 std::fs::write(
553 &path,
554 r#"[
555 {
556 "id": "good-agent",
557 "name": "Good",
558 "identity": "You are fine.",
559 "scenarios": [{"input": "ping", "expect": "pong"}],
560 "enabled": true,
561 "context": "self"
562 },
563 {
564 "id": "typo-agent",
565 "name": "Typo",
566 "identity": "You were hand-edited.",
567 "scenarios": [{"input": "ping", "expect": "pong"}],
568 "enabled": true,
569 "context": "mine"
570 }
571]
572"#,
573 )
574 .unwrap();
575
576 let reg = DeclRegistry::at(&path);
577 assert_eq!(reg.list().len(), 2, "a typo must not empty the registry");
578 assert_eq!(
579 reg.get("typo-agent").unwrap().context,
580 ContextPolicy::Car,
581 "the mistyped policy falls back to the managed default"
582 );
583 assert_eq!(
584 reg.get("good-agent").unwrap().context,
585 ContextPolicy::SelfManaged,
586 "a valid neighbour is unaffected"
587 );
588
589 reg.upsert(spec("third")).unwrap();
590 assert_eq!(
591 DeclRegistry::at(&path).list().len(),
592 3,
593 "upsert after a tolerated typo must not have written an empty registry"
594 );
595 }
596}