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, schemars::JsonSchema)]
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, Serialize, Deserialize, schemars::JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum AgentCadenceTrigger {
49 Cron,
50 Interval,
51 Manual,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57pub struct AgentCadence {
58 pub trigger: AgentCadenceTrigger,
59 pub schedule: String,
62 #[serde(default)]
63 pub timezone: Option<String>,
64 pub phrase: String,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
82#[serde(rename_all = "lowercase")]
83pub enum ContextPolicy {
84 #[default]
91 Car,
92 #[serde(rename = "self")]
96 SelfManaged,
97}
98
99impl ContextPolicy {
100 pub fn is_car_managed(self) -> bool {
107 match self {
108 ContextPolicy::Car => true,
109 ContextPolicy::SelfManaged => false,
110 }
111 }
112
113 pub fn as_str(self) -> &'static str {
115 match self {
116 ContextPolicy::Car => "car",
117 ContextPolicy::SelfManaged => "self",
118 }
119 }
120}
121
122impl<'de> Deserialize<'de> for ContextPolicy {
123 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139 where
140 D: serde::Deserializer<'de>,
141 {
142 let raw = serde_json::Value::deserialize(deserializer)?;
146 match raw.as_str() {
147 Some("car") => Ok(ContextPolicy::Car),
148 Some("self") => Ok(ContextPolicy::SelfManaged),
149 _ => {
150 tracing::warn!(
151 value = %raw,
152 "declarative agent spec has an unrecognized `context` policy {raw}; \
153 expected \"car\" or \"self\" — using \"car\" (CAR manages the \
154 history). Fix the value in declagents.json to silence this."
155 );
156 Ok(ContextPolicy::Car)
157 }
158 }
159 }
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
168pub struct AgentBuilderDraft {
169 #[serde(default)]
171 pub template_id: String,
172 #[serde(default)]
173 pub name: String,
174 #[serde(default)]
175 pub responsibility: String,
176 #[serde(default)]
177 pub example: String,
178 #[serde(default)]
179 pub access: String,
180 #[serde(default)]
181 pub cadence: String,
182 #[serde(default)]
183 pub delivery: String,
184 #[serde(default)]
185 pub privacy: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct DeclarativeAgentSpec {
191 pub id: String,
193 pub name: String,
194 pub identity: String,
196 #[serde(default)]
200 pub tools: Vec<String>,
201 #[serde(default)]
203 pub denied_tools: Vec<String>,
204 #[serde(default)]
206 pub standing_goal: String,
207 #[serde(default)]
210 pub goal: Option<DeclarativeGoal>,
211 #[serde(default)]
214 pub cadence: Option<AgentCadence>,
215 #[serde(default)]
217 pub scenarios: Vec<Scenario>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub builder_draft: Option<AgentBuilderDraft>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub previous: Option<Box<DeclarativeAgentSpec>>,
226 #[serde(default = "default_true")]
228 pub enabled: bool,
229 #[serde(default)]
236 pub context: ContextPolicy,
237}
238
239fn default_true() -> bool {
240 true
241}
242
243impl DeclarativeAgentSpec {
244 pub fn validate(&self) -> Vec<String> {
246 let mut issues = Vec::new();
247 if !is_filename_safe(&self.id) {
248 issues.push(format!(
249 "invalid agent id (alphanumeric + -_.): {:?}",
250 self.id
251 ));
252 }
253 if self.name.trim().is_empty() {
254 issues.push("agent name is empty".into());
255 }
256 if self.identity.trim().is_empty() {
257 issues.push("agent identity (system prompt) is empty".into());
258 }
259 if let Some(goal) = &self.goal {
260 if goal.check.trim().is_empty() {
261 issues.push("agent goal.check is empty".into());
262 }
263 if goal.max_iterations == 0 || goal.max_iterations > 50 {
264 issues.push("agent goal.max_iterations must be between 1 and 50".into());
265 }
266 }
267 if self.scenarios.is_empty() {
273 issues.push("agent must have at least one acceptance scenario".into());
274 }
275 issues
276 }
277}
278
279fn is_filename_safe(id: &str) -> bool {
280 !id.is_empty()
281 && id != "."
282 && id != ".."
283 && id
284 .chars()
285 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
286}
287
288pub struct DeclRegistry {
293 path: PathBuf,
294}
295
296impl DeclRegistry {
297 pub fn user_default() -> Result<Self, String> {
303 if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
304 return Ok(Self {
305 path: PathBuf::from(p),
306 });
307 }
308 let root = car_home::root()
309 .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
310 Ok(Self {
311 path: root.join("declagents.json"),
312 })
313 }
314
315 pub fn at(path: impl Into<PathBuf>) -> Self {
316 Self { path: path.into() }
317 }
318
319 pub fn path(&self) -> &Path {
324 &self.path
325 }
326
327 fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
328 std::fs::read_to_string(&self.path)
329 .ok()
330 .and_then(|s| serde_json::from_str(&s).ok())
331 .unwrap_or_default()
332 }
333
334 fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
335 if let Some(parent) = self.path.parent() {
336 std::fs::create_dir_all(parent)
337 .map_err(|e| format!("create {}: {e}", parent.display()))?;
338 }
339 let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
340 let tmp = self.path.with_extension("json.tmp");
342 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
343 std::fs::rename(&tmp, &self.path)
344 .map_err(|e| format!("rename into {}: {e}", self.path.display()))
345 }
346
347 pub fn upsert(&self, mut spec: DeclarativeAgentSpec) -> Result<(), String> {
353 let issues = spec.validate();
354 if !issues.is_empty() {
355 return Err(format!("invalid declarative agent: {}", issues.join("; ")));
356 }
357 let mut all = self.read_all();
358 if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
359 let mut previous = existing.clone();
360 previous.previous = None;
361 spec.previous = Some(Box::new(previous));
362 *existing = spec;
363 } else {
364 spec.previous = None;
365 all.push(spec);
366 }
367 self.write_all(&all)
368 }
369
370 pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
371 self.read_all()
372 }
373
374 pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
375 self.read_all().into_iter().find(|s| s.id == id)
376 }
377
378 pub fn remove(&self, id: &str) -> Result<bool, String> {
379 let mut all = self.read_all();
380 let before = all.len();
381 all.retain(|s| s.id != id);
382 let removed = all.len() != before;
383 if removed {
384 self.write_all(&all)?;
385 }
386 Ok(removed)
387 }
388
389 pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
390 let mut all = self.read_all();
391 let spec = all
392 .iter_mut()
393 .find(|s| s.id == id)
394 .ok_or_else(|| format!("no declarative agent '{id}'"))?;
395 spec.enabled = on;
396 self.write_all(&all)
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn spec(id: &str) -> DeclarativeAgentSpec {
405 DeclarativeAgentSpec {
406 id: id.into(),
407 name: "Test".into(),
408 identity: "You are a test agent.".into(),
409 tools: vec!["read_file".into()],
410 denied_tools: vec![],
411 standing_goal: "be helpful".into(),
412 goal: None,
413 cadence: None,
414 scenarios: vec![Scenario {
415 input: "hi".into(),
416 expect: "ok".into(),
417 }],
418 builder_draft: None,
419 previous: None,
420 enabled: true,
421 context: ContextPolicy::default(),
422 }
423 }
424
425 fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
426 let dir = tempfile::tempdir().unwrap();
427 let reg = DeclRegistry::at(dir.path().join("declagents.json"));
428 (dir, reg)
429 }
430
431 #[test]
432 fn upsert_rejects_spec_with_no_scenarios() {
433 let (_d, reg) = temp_registry();
434 let mut s = spec("no-scenarios");
435 s.scenarios.clear();
436 let err = reg
437 .upsert(s)
438 .expect_err("zero-scenario spec must be rejected");
439 assert!(
440 err.contains("at least one acceptance scenario"),
441 "got: {err}"
442 );
443 }
444
445 #[test]
446 fn upsert_rejects_invalid_goal_contract() {
447 let (_d, reg) = temp_registry();
448 let mut s = spec("bad-goal");
449 s.goal = Some(DeclarativeGoal {
450 check: " ".into(),
451 max_iterations: 0,
452 });
453 let err = reg.upsert(s).expect_err("invalid goal must be rejected");
454 assert!(err.contains("goal.check"), "{err}");
455 assert!(err.contains("goal.max_iterations"), "{err}");
456 }
457
458 #[test]
459 fn upsert_list_get_remove_round_trip() {
460 let (_d, reg) = temp_registry();
461 reg.upsert(spec("email-bot")).unwrap();
462 reg.upsert(spec("note-taker")).unwrap();
463 assert_eq!(reg.list().len(), 2);
464 assert_eq!(reg.get("email-bot").unwrap().name, "Test");
465 let mut updated = spec("email-bot");
467 updated.name = "Renamed".into();
468 reg.upsert(updated).unwrap();
469 assert_eq!(reg.list().len(), 2);
470 let current = reg.get("email-bot").unwrap();
471 assert_eq!(current.name, "Renamed");
472 assert_eq!(current.previous.as_deref().unwrap().name, "Test");
473 assert!(current.previous.as_deref().unwrap().previous.is_none());
474 assert!(reg.remove("email-bot").unwrap());
475 assert!(!reg.remove("email-bot").unwrap());
476 assert_eq!(reg.list().len(), 1);
477 }
478
479 #[test]
480 fn set_enabled_toggles() {
481 let (_d, reg) = temp_registry();
482 reg.upsert(spec("a")).unwrap();
483 reg.set_enabled("a", false).unwrap();
484 assert!(!reg.get("a").unwrap().enabled);
485 reg.set_enabled("a", true).unwrap();
486 assert!(reg.get("a").unwrap().enabled);
487 assert!(reg.set_enabled("missing", true).is_err());
488 }
489
490 #[test]
491 fn invalid_spec_is_rejected() {
492 let (_d, reg) = temp_registry();
493 let mut bad = spec("../escape");
494 assert!(reg.upsert(bad.clone()).is_err());
495 bad.id = "ok".into();
496 bad.identity = " ".into();
497 assert!(reg.upsert(bad).is_err());
498 }
499
500 #[test]
501 fn get_preserves_existing_registry_bytes_and_path_is_derived() {
502 let dir = tempfile::tempdir().unwrap();
503 let path = dir.path().join("declagents.json");
504 let fixture = r#"[
505 {
506 "id": "existing-agent",
507 "name": "Existing",
508 "identity": "You preserve old state.",
509 "tools": [],
510 "standing_goal": "remain compatible",
511 "scenarios": [{"input": "ping", "expect": "pong"}],
512 "enabled": true
513 }
514]
515"#;
516 std::fs::write(&path, fixture).unwrap();
517 let reg = DeclRegistry::at(&path);
518
519 assert_eq!(reg.path(), path.as_path());
520 assert_eq!(reg.get("existing-agent").unwrap().name, "Existing");
521 assert_eq!(
522 std::fs::read(&path).unwrap(),
523 fixture.as_bytes(),
524 "a metadata read must not write registry_path into persisted user state"
525 );
526 }
527
528 #[test]
529 fn persists_across_handles() {
530 let dir = tempfile::tempdir().unwrap();
531 let path = dir.path().join("declagents.json");
532 DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
533 assert!(DeclRegistry::at(&path).get("persist").is_some());
535 }
536
537 #[test]
538 fn cadence_round_trips_and_is_optional_for_existing_specs() {
539 let mut s = spec("cadence");
540 s.cadence = Some(AgentCadence {
541 trigger: AgentCadenceTrigger::Cron,
542 schedule: "30 8 * * 1-5".into(),
543 timezone: Some("America/New_York".into()),
544 phrase: "Weekdays at 8:30".into(),
545 });
546 let json = serde_json::to_value(&s).unwrap();
547 assert_eq!(json["cadence"]["trigger"], "cron");
548 assert_eq!(json["cadence"]["schedule"], "30 8 * * 1-5");
549 assert_eq!(json["cadence"]["timezone"], "America/New_York");
550 assert_eq!(json["cadence"]["phrase"], "Weekdays at 8:30");
551 assert_eq!(
552 serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
553 s
554 );
555
556 let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
557 "id": "legacy",
558 "name": "Legacy",
559 "identity": "You predate cadence.",
560 "scenarios": [{"input": "ping", "expect": "pong"}],
561 }))
562 .unwrap();
563 assert_eq!(legacy.cadence, None);
564 }
565
566 #[test]
567 fn builder_draft_and_previous_round_trip_with_one_level_of_history() {
568 let (_d, reg) = temp_registry();
569 let mut first = spec("editable");
570 first.builder_draft = Some(AgentBuilderDraft {
571 template_id: "researchBrief".into(),
572 name: "Research Brief".into(),
573 responsibility: "Research assigned questions".into(),
574 example: "Compare three options".into(),
575 access: "Public web".into(),
576 cadence: "When assigned".into(),
577 delivery: "Save in Work".into(),
578 privacy: "Keep supplied files local".into(),
579 });
580 reg.upsert(first.clone()).unwrap();
581
582 let mut second = first;
583 second.standing_goal = "Deliver cited research".into();
584 second.builder_draft.as_mut().unwrap().cadence = "Every weekday".into();
585 second.previous = Some(Box::new(spec("ignored-history")));
587 reg.upsert(second).unwrap();
588
589 let loaded = DeclRegistry::at(reg.path()).get("editable").unwrap();
590 assert_eq!(
591 loaded.builder_draft.as_ref().unwrap().cadence,
592 "Every weekday"
593 );
594 let previous = loaded.previous.as_deref().unwrap();
595 assert_eq!(
596 previous.builder_draft.as_ref().unwrap().cadence,
597 "When assigned"
598 );
599 assert!(previous.previous.is_none());
600
601 let json = serde_json::to_string(&loaded).unwrap();
602 let round_tripped: DeclarativeAgentSpec = serde_json::from_str(&json).unwrap();
603 assert_eq!(round_tripped, loaded);
604 }
605
606 #[test]
607 fn legacy_spec_defaults_builder_history_to_none() {
608 let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
609 "id": "legacy",
610 "name": "Legacy",
611 "identity": "You predate editing.",
612 "scenarios": [{"input": "ping", "expect": "pong"}]
613 }))
614 .unwrap();
615 assert!(legacy.builder_draft.is_none());
616 assert!(legacy.previous.is_none());
617 }
618
619 #[test]
620 fn context_policy_round_trips_through_the_spec() {
621 let mut s = spec("ctx");
623 assert_eq!(s.context, ContextPolicy::Car, "default is CAR-managed");
624 let json = serde_json::to_value(&s).unwrap();
625 assert_eq!(json["context"], "car");
626 assert_eq!(
627 serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
628 s
629 );
630
631 s.context = ContextPolicy::SelfManaged;
632 let json = serde_json::to_value(&s).unwrap();
633 assert_eq!(json["context"], "self");
634 assert_eq!(
635 serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
636 s
637 );
638 }
639
640 #[test]
641 fn a_spec_written_before_the_context_field_still_loads_as_car_managed() {
642 let dir = tempfile::tempdir().unwrap();
645 let path = dir.path().join("declagents.json");
646 std::fs::write(
647 &path,
648 r#"[
649 {
650 "id": "legacy-agent",
651 "name": "Legacy",
652 "identity": "You predate the context field.",
653 "tools": [],
654 "standing_goal": "remain compatible",
655 "scenarios": [{"input": "ping", "expect": "pong"}],
656 "enabled": true
657 }
658]
659"#,
660 )
661 .unwrap();
662
663 let loaded = DeclRegistry::at(&path).get("legacy-agent").unwrap();
664 assert_eq!(loaded.context, ContextPolicy::Car);
665 assert!(loaded.context.is_car_managed());
666 }
667
668 #[test]
669 fn a_mistyped_context_value_warns_and_defaults_instead_of_failing_the_parse() {
670 let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
676 "id": "typo",
677 "name": "Typo",
678 "identity": "x",
679 "scenarios": [{"input": "a", "expect": "b"}],
680 "context": "mine",
681 }))
682 .expect("a mistyped context policy must still load");
683 assert_eq!(spec.context, ContextPolicy::Car);
684
685 for bad in [
688 serde_json::json!(5),
689 serde_json::json!(null),
690 serde_json::json!({"who": "me"}),
691 serde_json::json!("Self"),
692 ] {
693 let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
694 "id": "typo",
695 "name": "Typo",
696 "identity": "x",
697 "scenarios": [{"input": "a", "expect": "b"}],
698 "context": bad,
699 }))
700 .expect("a malformed context policy must still load");
701 assert_eq!(spec.context, ContextPolicy::Car);
702 }
703 }
704
705 #[test]
706 fn a_mistyped_context_value_cannot_empty_the_registry() {
707 let dir = tempfile::tempdir().unwrap();
711 let path = dir.path().join("declagents.json");
712 std::fs::write(
713 &path,
714 r#"[
715 {
716 "id": "good-agent",
717 "name": "Good",
718 "identity": "You are fine.",
719 "scenarios": [{"input": "ping", "expect": "pong"}],
720 "enabled": true,
721 "context": "self"
722 },
723 {
724 "id": "typo-agent",
725 "name": "Typo",
726 "identity": "You were hand-edited.",
727 "scenarios": [{"input": "ping", "expect": "pong"}],
728 "enabled": true,
729 "context": "mine"
730 }
731]
732"#,
733 )
734 .unwrap();
735
736 let reg = DeclRegistry::at(&path);
737 assert_eq!(reg.list().len(), 2, "a typo must not empty the registry");
738 assert_eq!(
739 reg.get("typo-agent").unwrap().context,
740 ContextPolicy::Car,
741 "the mistyped policy falls back to the managed default"
742 );
743 assert_eq!(
744 reg.get("good-agent").unwrap().context,
745 ContextPolicy::SelfManaged,
746 "a valid neighbour is unaffected"
747 );
748
749 reg.upsert(spec("third")).unwrap();
750 assert_eq!(
751 DeclRegistry::at(&path).list().len(),
752 3,
753 "upsert after a tolerated typo must not have written an empty registry"
754 );
755 }
756}