use serde::{Deserialize, Serialize};
use crate::agent::{PatternFilter, SnapshotPolicy};
pub const MANIFEST_API_VERSION: &str = "mur.run/v1";
pub const MANIFEST_KIND_AGENT: &str = "AgentManifest";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentManifest {
pub api_version: String,
pub kind: String,
pub metadata: ManifestMetadata,
pub spec: ManifestSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestMetadata {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ManifestSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sys_prompt_ref: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default)]
pub patterns: ManifestPatterns,
#[serde(default)]
pub resources: ManifestResources,
#[serde(default)]
pub entitlements: ManifestEntitlements,
#[serde(default)]
pub federation: ManifestFederation,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ManifestPatterns {
#[serde(default)]
pub filter: PatternFilter,
#[serde(default)]
pub snapshot_policy: SnapshotPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ManifestResources {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget_per_day_usd: Option<f64>,
#[serde(default)]
pub max_concurrent_sessions: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ManifestEntitlements {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub network: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filesystem_read: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filesystem_write: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ManifestFederation {
#[serde(default)]
pub sync_interval_minutes: u32,
}
impl AgentManifest {
pub fn from_yaml(yaml: &str) -> anyhow::Result<Self> {
let m: AgentManifest = serde_yaml_ng::from_str(yaml)?;
anyhow::ensure!(
m.api_version == MANIFEST_API_VERSION,
"expected apiVersion '{}', got '{}'",
MANIFEST_API_VERSION,
m.api_version
);
anyhow::ensure!(
m.kind == MANIFEST_KIND_AGENT,
"expected kind '{}', got '{}'",
MANIFEST_KIND_AGENT,
m.kind
);
Ok(m)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
apiVersion: mur.run/v1
kind: AgentManifest
metadata:
name: code-reviewer
workspace: default
spec:
sysPromptRef: sys_prompt.md
skills:
- skills/review-rust.md
patterns:
filter:
tier: [project, core]
importanceMin: 0.5
snapshotPolicy: pull-on-start
resources:
tokenBudgetPerDayUsd: 5.0
maxConcurrentSessions: 3
entitlements:
network: [github.com]
filesystemRead: [/Users/david/Projects]
federation:
syncIntervalMinutes: 15
"#;
#[test]
fn parse_example_manifest() {
let m = AgentManifest::from_yaml(EXAMPLE).unwrap();
assert_eq!(m.metadata.name, "code-reviewer");
assert_eq!(m.spec.skills.len(), 1);
assert!((m.spec.resources.token_budget_per_day_usd.unwrap() - 5.0).abs() < f64::EPSILON);
assert_eq!(m.spec.federation.sync_interval_minutes, 15);
}
#[test]
fn rejects_wrong_api_version() {
let bad = EXAMPLE.replace("mur.run/v1", "mur.run/v2");
assert!(AgentManifest::from_yaml(&bad).is_err());
}
#[test]
fn rejects_wrong_kind() {
let bad = EXAMPLE.replace("AgentManifest", "WorkflowManifest");
assert!(AgentManifest::from_yaml(&bad).is_err());
}
}