Skip to main content

mur_common/
manifest.rs

1//! AgentManifest — declarative K8s-style spec for creating/updating an agent.
2//!
3//! Used by both `mur agent apply` (local) and `mur commander apply` (remote).
4//! Schema: `mur.run/v1 / AgentManifest`. Applied via `mur agent apply -f manifest.yaml`.
5
6use serde::{Deserialize, Serialize};
7
8use crate::agent::{PatternFilter, SnapshotPolicy};
9
10pub const MANIFEST_API_VERSION: &str = "mur.run/v1";
11pub const MANIFEST_KIND_AGENT: &str = "AgentManifest";
12
13/// Top-level declarative agent manifest (K8s-style).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct AgentManifest {
17    /// Must be `"mur.run/v1"`.
18    pub api_version: String,
19    /// Must be `"AgentManifest"`.
20    pub kind: String,
21    pub metadata: ManifestMetadata,
22    pub spec: ManifestSpec,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ManifestMetadata {
27    pub name: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub workspace: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33#[serde(rename_all = "camelCase")]
34pub struct ManifestSpec {
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub profile_ref: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub sys_prompt_ref: Option<String>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub skills: Vec<String>,
41    #[serde(default)]
42    pub patterns: ManifestPatterns,
43    #[serde(default)]
44    pub resources: ManifestResources,
45    #[serde(default)]
46    pub entitlements: ManifestEntitlements,
47    #[serde(default)]
48    pub federation: ManifestFederation,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52#[serde(rename_all = "camelCase")]
53pub struct ManifestPatterns {
54    #[serde(default)]
55    pub filter: PatternFilter,
56    #[serde(default)]
57    pub snapshot_policy: SnapshotPolicy,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61#[serde(rename_all = "camelCase")]
62pub struct ManifestResources {
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub token_budget_per_day_usd: Option<f64>,
65    #[serde(default)]
66    pub max_concurrent_sessions: u32,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70#[serde(rename_all = "camelCase")]
71pub struct ManifestEntitlements {
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub network: Vec<String>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub filesystem_read: Vec<String>,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub filesystem_write: Vec<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81#[serde(rename_all = "camelCase")]
82pub struct ManifestFederation {
83    #[serde(default)]
84    pub sync_interval_minutes: u32,
85}
86
87impl AgentManifest {
88    /// Parse from YAML string. Returns an error if `apiVersion` or `kind` is wrong.
89    pub fn from_yaml(yaml: &str) -> anyhow::Result<Self> {
90        let m: AgentManifest = serde_yaml_ng::from_str(yaml)?;
91        anyhow::ensure!(
92            m.api_version == MANIFEST_API_VERSION,
93            "expected apiVersion '{}', got '{}'",
94            MANIFEST_API_VERSION,
95            m.api_version
96        );
97        anyhow::ensure!(
98            m.kind == MANIFEST_KIND_AGENT,
99            "expected kind '{}', got '{}'",
100            MANIFEST_KIND_AGENT,
101            m.kind
102        );
103        Ok(m)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    const EXAMPLE: &str = r#"
112apiVersion: mur.run/v1
113kind: AgentManifest
114metadata:
115  name: code-reviewer
116  workspace: default
117spec:
118  sysPromptRef: sys_prompt.md
119  skills:
120    - skills/review-rust.md
121  patterns:
122    filter:
123      tier: [project, core]
124      importanceMin: 0.5
125    snapshotPolicy: pull-on-start
126  resources:
127    tokenBudgetPerDayUsd: 5.0
128    maxConcurrentSessions: 3
129  entitlements:
130    network: [github.com]
131    filesystemRead: [/Users/david/Projects]
132  federation:
133    syncIntervalMinutes: 15
134"#;
135
136    #[test]
137    fn parse_example_manifest() {
138        let m = AgentManifest::from_yaml(EXAMPLE).unwrap();
139        assert_eq!(m.metadata.name, "code-reviewer");
140        assert_eq!(m.spec.skills.len(), 1);
141        assert!((m.spec.resources.token_budget_per_day_usd.unwrap() - 5.0).abs() < f64::EPSILON);
142        assert_eq!(m.spec.federation.sync_interval_minutes, 15);
143    }
144
145    #[test]
146    fn rejects_wrong_api_version() {
147        let bad = EXAMPLE.replace("mur.run/v1", "mur.run/v2");
148        assert!(AgentManifest::from_yaml(&bad).is_err());
149    }
150
151    #[test]
152    fn rejects_wrong_kind() {
153        let bad = EXAMPLE.replace("AgentManifest", "WorkflowManifest");
154        assert!(AgentManifest::from_yaml(&bad).is_err());
155    }
156}