Skip to main content

car_registry/
declarative.rs

1//! Declarative agents — in-daemon agents that need no external process.
2//!
3//! A [`DeclarativeAgentSpec`](crate::declarative::DeclarativeAgentSpec) is pure data: an identity (system prompt), a
4//! tool **allowlist** (names of tools the daemon already exposes), an optional
5//! deny list, a standing goal, an optional deterministic completion goal, and
6//! scenarios (test cases). The daemon runs it with a generic model→tool loop — there is no command to spawn, so a
7//! non-developer never installs Node/Python/anything.
8//!
9//! This is a **parallel registry** to [`crate::supervisor`], deliberately NOT
10//! an extension of `supervisor::AgentSpec`: that type's whole security model is
11//! the absolute-executable-path validation in `Supervisor::upsert` (the
12//! 2026-05 audit's control). A declarative agent has no command, so it must
13//! never travel through that path. `agents.list` read-merges declarative
14//! entries (tagged `kind:"declarative"`) for the unified host view.
15
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19/// A test case for a declarative agent: run it on `input`, the output must
20/// contain `expect` (a stable substring — not an exact match, so the contract
21/// tolerates benign model variation).
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Scenario {
24    pub input: String,
25    pub expect: String,
26}
27
28/// Deterministic completion contract for a declarative agent invocation.
29/// The daemon runs `check` in the same scratch worktree after each agent pass
30/// and re-drives until it exits 0 or `max_iterations` is exhausted.
31#[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/// Who owns the agent's conversation context across a multi-turn run.
43///
44/// The daemon already bounds the assistant and coder loops to the model's
45/// context window; a declarative agent had no such bound and no way to ask for
46/// one. This is that switch, per agent, named for who does the work rather than
47/// for a mechanism the spec author cannot see.
48///
49/// Deserialization is deliberately TOLERANT (see the hand-written impl below):
50/// an unrecognized value warns and falls back to [`ContextPolicy::Car`] rather
51/// than failing the parse. A strict impl would be a foot-gun here — the
52/// authoring guide teaches hand-editing `declagents.json`, and `DeclRegistry`
53/// treats an unparseable file as an empty registry that the next `upsert`
54/// writes back, so one mistyped policy would delete every agent on the box.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
56#[serde(rename_all = "lowercase")]
57pub enum ContextPolicy {
58    /// CAR manages it: before each model call the running history is compacted
59    /// to fit the model's context window, exactly as the assistant and coder
60    /// loops do (oldest middle turns dropped on a turn boundary, the system
61    /// prompt and the original task pinned, a `[history compacted:` notice left
62    /// behind). The default, so an agent that never thinks about context still
63    /// gets a bounded one.
64    #[default]
65    Car,
66    /// The agent author manages it. CAR never compacts this agent's history —
67    /// the spec owns whatever summarizing, truncation or externalization it
68    /// wants. Choose it deliberately: nothing else bounds the transcript.
69    #[serde(rename = "self")]
70    SelfManaged,
71}
72
73impl ContextPolicy {
74    /// Whether CAR compacts this agent's history.
75    ///
76    /// Exhaustive on purpose (repo rule): a `matches!` here would make every
77    /// future variant silently self-managed — i.e. would turn "we added a
78    /// policy" into "we switched compaction off for it", which is the failure
79    /// mode with no symptom until a run overflows.
80    pub fn is_car_managed(self) -> bool {
81        match self {
82            ContextPolicy::Car => true,
83            ContextPolicy::SelfManaged => false,
84        }
85    }
86
87    /// The on-disk / on-the-wire spelling.
88    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    /// Accepts `"car"` and `"self"`; ANYTHING else warns and yields `Car`.
98    ///
99    /// The strict derive returned `unknown variant`, and one bad character in a
100    /// hand-edited `declagents.json` then travelled the whole swallow chain —
101    /// [`DeclRegistry::read_all`] maps a parse error to an empty registry, and
102    /// the next `upsert` persists that emptiness — so a typo in an optional
103    /// field could unregister every agent the user had. A field whose only two
104    /// values both mean "keep running" must never be able to do that. The
105    /// fallback is the managed default, which is also the safe one: the worst
106    /// outcome of guessing wrong is a bounded history for an author who wanted
107    /// to bound it themselves, and the warning says so.
108    ///
109    /// The spec id is not reachable from here (serde hands this impl only the
110    /// field's own value), so the warning names the value; the surrounding
111    /// spec is one `declagents.json` grep away.
112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113    where
114        D: serde::Deserializer<'de>,
115    {
116        // Through `serde_json::Value` rather than `String` so a non-string
117        // (`"context": 5`, `null`, an object) takes the same tolerant path
118        // instead of the parse error this impl exists to prevent.
119        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/// A declarative, in-daemon agent.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct DeclarativeAgentSpec {
139    /// Filename-safe id (derived from the project slug).
140    pub id: String,
141    pub name: String,
142    /// System prompt — who the agent is and how it behaves.
143    pub identity: String,
144    /// Allowlist of tool names the agent may call. The daemon exposes ONLY
145    /// these to the model; anything else is invisible. Empty = no tools
146    /// (a pure-reasoning agent).
147    #[serde(default)]
148    pub tools: Vec<String>,
149    /// Extra hard denials layered under the allowlist (belt and suspenders).
150    #[serde(default)]
151    pub denied_tools: Vec<String>,
152    /// The agent's persistent objective, prepended to every run.
153    #[serde(default)]
154    pub standing_goal: String,
155    /// Optional deterministic completion contract. This is not shown to the
156    /// model as a tool; it is the runtime-owned verifier for each invocation.
157    #[serde(default)]
158    pub goal: Option<DeclarativeGoal>,
159    /// Acceptance scenarios — the contract the coder→agent loop drives to green.
160    #[serde(default)]
161    pub scenarios: Vec<Scenario>,
162    /// Lifecycle toggle. A disabled agent stays registered but won't run.
163    #[serde(default = "default_true")]
164    pub enabled: bool,
165    /// Who manages the conversation context of a run: CAR (compaction keyed to
166    /// the model's context window, the default) or the agent itself.
167    ///
168    /// `#[serde(default)]` is load-bearing: every spec already written to
169    /// `declagents.json` predates this field and must keep loading, with the
170    /// same managed behavior a new one gets.
171    #[serde(default)]
172    pub context: ContextPolicy,
173}
174
175fn default_true() -> bool {
176    true
177}
178
179impl DeclarativeAgentSpec {
180    /// Structural problems that make a spec unusable. Empty = valid.
181    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        // Scenarios are the contract the coder→agent loop drives to green; an
204        // agent with none was never validated against any example. The builder
205        // always writes at least one, so a zero-scenario spec is a hand-edit or
206        // a malformed write — reject it at upsert rather than register an
207        // unverifiable agent.
208        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
224/// File-backed registry of declarative agents at `declagents.json` under the
225/// CAR state root (`CAR_DECLAGENTS_PATH` overrides for tests/embedders).
226/// Atomic write-through — the on-disk JSON is authoritative across restarts,
227/// mirroring the supervisor manifest's hygiene.
228pub struct DeclRegistry {
229    path: PathBuf,
230}
231
232impl DeclRegistry {
233    /// `declagents.json` under the CAR state root — `$CAR_HOME` when set,
234    /// otherwise `~/.car`. `CAR_DECLAGENTS_PATH` is the narrower override and
235    /// still wins over both: it names the file outright, so a caller that set
236    /// it means that exact path regardless of where the rest of the state
237    /// lives.
238    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    /// Exact file this registry reads and writes.
256    ///
257    /// Location is registry metadata, not part of [`DeclarativeAgentSpec`], so
258    /// exposing it cannot leak into persisted `declagents.json` records.
259    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        // Atomic: write a sibling temp then rename over the target.
277        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    /// Insert or replace by id. Validates the spec first.
284    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        // Upsert replaces, doesn't duplicate.
391        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        // A fresh handle reads the same on-disk state.
456        assert!(DeclRegistry::at(&path).get("persist").is_some());
457    }
458
459    #[test]
460    fn context_policy_round_trips_through_the_spec() {
461        // The wire/disk spelling is the spec author's spelling: `car` / `self`.
462        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        // Every agent already on disk predates the field. It must load — with
483        // the managed default, not a parse error and not an unmanaged run.
484        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        // The blast radius of strictness here is the whole registry, not one
511        // field: `read_all` maps a parse error to an empty Vec and the next
512        // `upsert` writes that back. So a typo in a hand-edited spec — which
513        // the authoring guide invites — must degrade to the managed default
514        // (with a warning), never to "every agent deleted".
515        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        // Non-strings take the same path (`"context": 5` is the same class of
526        // hand-edit), and so does a wrong case.
527        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        // The chain the test above exists to break, end to end: a file holding
548        // two agents, one of them hand-edited wrong, must still list two — and
549        // an upsert on top of it must not persist an empty registry.
550        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}