car-registry 0.30.0

File-based agent registry + lifecycle supervisor for Common Agent Runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Manifest install-time validation (Parslee-ai/car#182 phase 3).
//!
//! Three responsibilities, all run *before* the supervisor accepts
//! a manifest into the active set:
//!
//! 1. **`car_min_version` enforcement.** Parse semver, compare to
//!    the runtime's published version, reject manifests whose
//!    floor is higher than what this car build provides.
//! 2. **Capability negotiation.** Compare `manifest.capabilities.required`
//!    against the host's advertisement. Every required capability
//!    must appear in the host's `provides`; fail-closed when even
//!    one is missing. `optional` and `denied` are inspected for
//!    informational purposes (telemetry / future policy
//!    enforcement) but don't gate install.
//! 3. **Per-version resolution.** When two installed manifests
//!    share `<namespace>.<name>`, unqualified addressing
//!    (`car start parslee/ui-improvement`) resolves to the
//!    highest installed semver; explicit `@version` pins.
//!
//! The validator is a free function (`install_check`) called by
//! `Supervisor::install_manifest`. Tests live alongside.

use std::collections::BTreeMap;

use car_bundle::{AgentManifest, CapabilityDeclarations};
use semver::{Version, VersionReq};

use crate::supervisor::SupervisorError;

/// What the runtime claims it can provide to a contributed agent.
/// Keyed by namespace (`inference`, `storage`, `a2ui`, …); each
/// namespace maps to a list of feature identifiers
/// (`text-generation`, `persistent-kv`, `render_report.subscribe`).
///
/// Host code populates this once at boot (or per-session if the
/// scope ever varies) and passes it to `install_check`. The list
/// is intentionally string-typed: the capability vocabulary lives
/// in `docs/agent-bundle-spec.md` + `docs/proposals/contributed-agents.md`,
/// not in code, so vocabulary growth doesn't require recompilation.
#[derive(Debug, Clone, Default)]
pub struct HostCapabilities {
    pub provides: BTreeMap<String, Vec<String>>,
    /// The runtime's published version, used to validate
    /// `manifest.runtime.car_min_version`. Set by callers from
    /// `env!("CARGO_PKG_VERSION")`.
    pub car_version: String,
}

impl HostCapabilities {
    /// Helper for setup: register one or more features under a
    /// capability namespace.
    pub fn provide(
        mut self,
        namespace: impl Into<String>,
        features: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        let entry = self.provides.entry(namespace.into()).or_default();
        for f in features {
            let s = f.into();
            if !entry.contains(&s) {
                entry.push(s);
            }
        }
        self
    }

    /// Returns `true` if this host advertises the given namespace
    /// + feature pair.
    pub fn satisfies(&self, namespace: &str, feature: &str) -> bool {
        self.provides
            .get(namespace)
            .is_some_and(|features| features.iter().any(|f| f == feature))
    }

    /// Default advertisement for a v0.8 car-server daemon. Lists
    /// the capabilities the runtime genuinely provides today; new
    /// features are added here as they ship (and listed in
    /// `docs/agent-bundle-spec.md` + the proposal).
    ///
    /// Callers that need a more restricted advertisement (e.g.,
    /// a daemon running with `--no-inference` or in air-gapped
    /// mode) start from this default and remove namespaces.
    pub fn daemon_default(car_version: impl Into<String>) -> Self {
        Self {
            car_version: car_version.into(),
            provides: BTreeMap::from([
                (
                    "inference".to_string(),
                    vec![
                        "text-generation".to_string(),
                        "embedding".to_string(),
                        "classification".to_string(),
                        "tool-use".to_string(),
                    ],
                ),
                (
                    "storage".to_string(),
                    vec![
                        "persistent-kv".to_string(),
                        "persistent-journal".to_string(),
                        "persistent-graph".to_string(),
                        "temporary".to_string(),
                    ],
                ),
                (
                    "a2ui".to_string(),
                    vec![
                        "render_report.subscribe".to_string(),
                        "render_report.emit".to_string(),
                        "patch_components.emit".to_string(),
                        "surface_subscribe".to_string(),
                    ],
                ),
                (
                    "a2a".to_string(),
                    vec!["message_send".to_string(), "task_subscribe".to_string()],
                ),
            ]),
        }
    }
}

/// Result of running `install_check` on a manifest. The success
/// path carries the optional list of `optional` capabilities the
/// host couldn't satisfy — informational so the caller can warn
/// the user that those code paths in the agent will degrade.
#[derive(Debug, Clone, Default)]
pub struct InstallCheckReport {
    pub missing_optional: Vec<(String, String)>,
}

/// Validate a manifest against the host's capability + version
/// advertisement. Returns an `InstallCheckReport` on success, or
/// a `SupervisorError::Other` describing the first blocker on
/// failure.
///
/// Returns errors for: missing required capability, version
/// requirement parse failure, version requirement not satisfied
/// by the host's `car_version`.
pub fn install_check(
    manifest: &AgentManifest,
    host: &HostCapabilities,
) -> Result<InstallCheckReport, SupervisorError> {
    if let Some(runtime) = &manifest.runtime {
        if let Some(min) = &runtime.car_min_version {
            check_car_min_version(min, &host.car_version, &manifest.agent.id)?;
        }
    }
    let mut report = InstallCheckReport::default();
    if let Some(caps) = &manifest.capabilities {
        check_required(caps, host, &manifest.agent.id)?;
        for (namespace, features) in &caps.optional {
            for feature in features {
                if !host.satisfies(namespace, feature) {
                    report
                        .missing_optional
                        .push((namespace.clone(), feature.clone()));
                }
            }
        }
    }
    Ok(report)
}

fn check_car_min_version(
    requirement: &str,
    host_version: &str,
    agent_id: &str,
) -> Result<(), SupervisorError> {
    // Accept either a bare semver ("0.8.0") interpreted as a
    // minimum, OR a cargo-style requirement (">=0.8, <0.9").
    // Bare-semver bumps to ">=" automatically.
    let parsed_req = parse_version_req(requirement).map_err(|e| {
        SupervisorError::Other(format!(
            "agent `{agent_id}` has invalid car_min_version `{requirement}`: {e}"
        ))
    })?;
    let host = Version::parse(host_version).map_err(|e| {
        // The host's own version is malformed — that's a build
        // misconfiguration, not the manifest's fault. Surface it
        // clearly.
        SupervisorError::Other(format!(
            "host car_version `{host_version}` is not valid semver: {e}"
        ))
    })?;
    if !parsed_req.matches(&host) {
        return Err(SupervisorError::Other(format!(
            "agent `{agent_id}` requires car `{requirement}` but host runtime is `{host_version}`"
        )));
    }
    Ok(())
}

fn parse_version_req(s: &str) -> Result<VersionReq, semver::Error> {
    if let Ok(v) = Version::parse(s.trim()) {
        // Bare semver → `>=<v>`. Matches cargo's behavior for
        // bare-string version specs in `Cargo.toml`.
        return Ok(VersionReq::parse(&format!(">={v}"))?);
    }
    VersionReq::parse(s)
}

fn check_required(
    caps: &CapabilityDeclarations,
    host: &HostCapabilities,
    agent_id: &str,
) -> Result<(), SupervisorError> {
    for (namespace, features) in &caps.required {
        for feature in features {
            if !host.satisfies(namespace, feature) {
                return Err(SupervisorError::Other(format!(
                    "agent `{agent_id}` requires `{namespace}.{feature}` but host does not provide it"
                )));
            }
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------
// Per-version resolution
// ---------------------------------------------------------------------

/// Resolve an unqualified `<namespace>/<name>` reference (or
/// `<id>` for legacy entries with no namespace) to the highest
/// installed semver. Returns `None` when no matching agent is
/// installed. Explicit `<namespace>/<name>@<version>` pins are
/// the caller's responsibility — this helper only handles the
/// unqualified case.
pub fn resolve_highest_version<'a>(
    candidates: impl IntoIterator<Item = &'a AgentManifest>,
    namespace: Option<&str>,
    name: &str,
) -> Option<&'a AgentManifest> {
    let mut best: Option<(Version, &AgentManifest)> = None;
    let mut fallback: Option<&AgentManifest> = None;
    for m in candidates {
        let m_name = m.agent.name.as_str();
        let m_namespace = m.agent.namespace.as_deref();
        // Match by name + optional namespace. When the caller
        // doesn't supply a namespace, accept any namespace; this
        // is the legacy-id path.
        let name_matches = m_name == name || m.agent.id == name;
        let namespace_matches = match (namespace, m_namespace) {
            (Some(want), Some(have)) => want == have,
            (None, _) => true,
            _ => false,
        };
        if !(name_matches && namespace_matches) {
            continue;
        }
        match m
            .agent
            .version
            .as_deref()
            .and_then(|v| Version::parse(v).ok())
        {
            Some(parsed) => match best.as_ref() {
                Some((b, _)) if &parsed <= b => {}
                _ => best = Some((parsed, m)),
            },
            None => {
                // Legacy / unversioned entry — only used as fallback
                // when no versioned manifest matched.
                if fallback.is_none() {
                    fallback = Some(m);
                }
            }
        }
    }
    best.map(|(_, m)| m).or(fallback)
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_bundle::{AgentIdentity, ExternalProcessTransport, RuntimeRequirements, TransportSpec};

    fn external_manifest(id: &str, version: Option<&str>) -> AgentManifest {
        AgentManifest {
            agent: AgentIdentity {
                id: id.into(),
                name: id.into(),
                namespace: Some("parslee".into()),
                version: version.map(str::to_string),
                description: None,
                license: None,
                homepage: None,
            },
            publisher: None,
            runtime: None,
            lifecycle: None,
            transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
                command: Some("/usr/local/bin/agent".into()),
                interpreter: None,
                binary_url: None,
                sha256: Some("abc".into()),
                health_url: None,
                args: vec![],
                cwd: None,
                env: BTreeMap::new(),
                restart: car_bundle::RestartPolicy::OnFailure,
                max_restarts: 10,
                backoff_secs: 5,
                auto_start: false,
                token: String::new(),
            }),
            capabilities: None,
        }
    }

    #[test]
    fn install_check_passes_when_no_capabilities_declared() {
        let m = external_manifest("alpha", Some("0.1.0"));
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let report = install_check(&m, &host).expect("no requirements means pass");
        assert!(report.missing_optional.is_empty());
    }

    #[test]
    fn install_check_fails_on_missing_required_capability() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.capabilities = Some(CapabilityDeclarations {
            required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
            ..Default::default()
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let err = install_check(&m, &host).expect_err("missing capability must fail");
        assert!(
            err.to_string().contains("inference.text-generation"),
            "expected missing-cap reason, got: {err}"
        );
    }

    #[test]
    fn install_check_records_missing_optional_capabilities() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.capabilities = Some(CapabilityDeclarations {
            required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
            optional: BTreeMap::from([
                ("inference".into(), vec!["embedding".into()]),
                ("a2a".into(), vec!["message_send".into()]),
            ]),
            ..Default::default()
        });
        let host = HostCapabilities::default()
            .provide("inference", ["text-generation"])
            // Note: embedding + message_send NOT advertised.
            ;
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..host
        };
        let report = install_check(&m, &host).expect("required satisfied");
        assert_eq!(report.missing_optional.len(), 2);
        let names: Vec<&str> = report
            .missing_optional
            .iter()
            .map(|(_n, f)| f.as_str())
            .collect();
        assert!(names.contains(&"embedding"));
        assert!(names.contains(&"message_send"));
    }

    #[test]
    fn install_check_rejects_when_car_min_version_too_high() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some("0.9.0".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let err = install_check(&m, &host).expect_err("min version unmet must fail");
        assert!(err.to_string().contains("0.9.0"));
        assert!(err.to_string().contains("0.8.0"));
    }

    #[test]
    fn install_check_accepts_bare_semver_as_minimum() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some("0.7.0".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        install_check(&m, &host).expect("bare semver is interpreted as `>=`");
    }

    #[test]
    fn install_check_accepts_cargo_style_requirement() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some(">=0.8, <0.9".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.5".into(),
            ..Default::default()
        };
        install_check(&m, &host).expect("range requirement satisfied");
    }

    #[test]
    fn resolve_highest_version_picks_max_semver() {
        let v1 = external_manifest("ui", Some("0.1.0"));
        let v2 = external_manifest("ui", Some("0.2.0"));
        let v3 = external_manifest("ui", Some("0.1.5"));
        let all = vec![&v1, &v2, &v3];
        let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
        assert_eq!(resolved.agent.version.as_deref(), Some("0.2.0"));
    }

    #[test]
    fn resolve_falls_back_to_unversioned_when_no_versioned_match() {
        let mut legacy = external_manifest("ui", None);
        legacy.agent.namespace = None;
        let all = vec![&legacy];
        let resolved = resolve_highest_version(all.iter().copied(), None, "ui").unwrap();
        assert!(resolved.agent.version.is_none());
    }

    #[test]
    fn resolve_versioned_wins_over_unversioned() {
        let mut legacy = external_manifest("ui", None);
        legacy.agent.namespace = None;
        let versioned = external_manifest("ui", Some("0.1.0"));
        let all = vec![&legacy, &versioned];
        let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
        // Versioned wins because it matches the namespace exactly,
        // and a versioned match beats a no-namespace fallback.
        assert_eq!(resolved.agent.version.as_deref(), Some("0.1.0"));
    }
}