- 18 agent specs across Software, Support, Marketing, Cross-cutting (orchestrator + 7 software + 5 support + 3 cross-cutting + leads-finder) - 8 reusable skills (handoff, pr-review, dokploy-deploy, campaign-structure, content-calendar, ticket-triage, license-lifecycle, invoice-draft) - 3 JSON schemas: handoff, deliverable, approval - Policies: autonomy levels, tool risk classification, compliance (DSGVO/GoBD/UWG) - routing.toml: model tiers T1/T2/T3 + provider config (PII gating) - Rust workspace (5 crates, all compile + 16 tests pass): - aos-mcp: tool registry + risk tagging - aos-policy: autonomy levels, budget enforcement, approval requests - aos-license: ed25519 license issue/verify (real crypto + CLI binary) - aos-ledger: integer-cents money math, gapless numbering, VAT, audit chain - aos-sync: frontmatter validator (18/18 specs pass), CLI binary - CLAUDE.md entry point with architecture overview + roadmap
270 lines
8.7 KiB
Rust
270 lines
8.7 KiB
Rust
//! Agent spec parsing + validation.
|
|
//!
|
|
//! An agent spec is a markdown file with YAML frontmatter. The required
|
|
//! fields mirror the format documented in `CLAUDE.md`. Validation fails fast
|
|
//! on missing fields so a broken spec never reaches a runtime.
|
|
|
|
use anyhow::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use thiserror::Error;
|
|
use walkdir::WalkDir;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum SpecError {
|
|
#[error("missing required field '{0}' in {1}")]
|
|
MissingField(&'static str, String),
|
|
#[error("could not read file {0}: {1}")]
|
|
Io(String, String),
|
|
#[error("could not parse frontmatter in {0}: {1}")]
|
|
Parse(String, String),
|
|
}
|
|
|
|
/// The frontmatter of an agent spec. Mirrors `agents/<dept>/<name>.md`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentSpec {
|
|
pub name: String,
|
|
pub tier: String,
|
|
pub department: String,
|
|
pub reports_to: String,
|
|
pub autonomy: String,
|
|
#[serde(default)]
|
|
pub skills: Vec<String>,
|
|
#[serde(default)]
|
|
pub tools: Vec<String>,
|
|
#[serde(default)]
|
|
pub forbidden: Vec<String>,
|
|
#[serde(default)]
|
|
pub budget: Budget,
|
|
#[serde(default)]
|
|
pub language: Language,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct Budget {
|
|
#[serde(default)]
|
|
pub max_eur_per_task: f64,
|
|
#[serde(default)]
|
|
pub max_tokens: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct Language {
|
|
#[serde(default = "default_chat")]
|
|
pub chat: String,
|
|
#[serde(default = "default_deliverables")]
|
|
pub deliverables: String,
|
|
}
|
|
|
|
fn default_chat() -> String {
|
|
"tr".into()
|
|
}
|
|
fn default_deliverables() -> String {
|
|
"en".into()
|
|
}
|
|
|
|
const REQUIRED_FIELDS: &[&str] = &["name", "tier", "department", "reports_to", "autonomy"];
|
|
|
|
/// Walks `agents/` and parses every `.md` into an `AgentSpec`.
|
|
///
|
|
/// Returns the specs + any validation errors. A spec with errors is excluded
|
|
/// from the success list and reported in the error vec.
|
|
pub fn validate_agents(root: &Path) -> Result<(Vec<AgentSpec>, Vec<SpecError>)> {
|
|
let mut specs = Vec::new();
|
|
let mut errors = Vec::new();
|
|
|
|
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
|
|
let path = entry.path();
|
|
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
|
|
continue;
|
|
}
|
|
let rel = path.strip_prefix(root).unwrap_or(path).display().to_string();
|
|
let content = match std::fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
errors.push(SpecError::Io(rel, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
let frontmatter = match extract_frontmatter(&content) {
|
|
Some(fm) => fm,
|
|
None => {
|
|
errors.push(SpecError::Parse(rel, "no --- frontmatter block found".into()));
|
|
continue;
|
|
}
|
|
};
|
|
// Parse the YAML frontmatter as JSON-ish: convert simple YAML to JSON.
|
|
// (Agent frontmatter is flat — key: value + list items — so a minimal
|
|
// conversion suffices without a full YAML parser dep.)
|
|
let json_str = match yaml_to_json(frontmatter) {
|
|
Ok(j) => j,
|
|
Err(e) => {
|
|
errors.push(SpecError::Parse(rel, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
errors.push(SpecError::Parse(rel, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
// Check required fields.
|
|
let obj = match parsed.as_object() {
|
|
Some(o) => o,
|
|
None => {
|
|
errors.push(SpecError::Parse(rel, "frontmatter is not an object".into()));
|
|
continue;
|
|
}
|
|
};
|
|
let mut missing = Vec::new();
|
|
for field in REQUIRED_FIELDS {
|
|
if !obj.contains_key(*field) {
|
|
missing.push(*field);
|
|
}
|
|
}
|
|
if !missing.is_empty() {
|
|
errors.push(SpecError::MissingField(missing[0], rel));
|
|
continue;
|
|
}
|
|
// Deserialize into the typed struct (best-effort; ignore unknown fields).
|
|
let spec: AgentSpec = match serde_json::from_value(parsed) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
errors.push(SpecError::Parse(rel, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
specs.push(spec);
|
|
}
|
|
|
|
Ok((specs, errors))
|
|
}
|
|
|
|
/// Extracts the content between `---` fences at the top of a markdown file.
|
|
fn extract_frontmatter(content: &str) -> Option<&str> {
|
|
let trimmed = content.trim_start();
|
|
if !trimmed.starts_with("---") {
|
|
return None;
|
|
}
|
|
let after_first_fence = &trimmed[3..];
|
|
let end = after_first_fence.find("\n---")?;
|
|
Some(after_first_fence[..end].trim())
|
|
}
|
|
|
|
/// Minimal YAML→JSON converter for flat agent-spec frontmatter.
|
|
///
|
|
/// Supports only the subset we use: `key: value`, `key: [a, b, c]` inline
|
|
/// arrays, and block arrays (`- item`). Nested objects (like `budget:` with
|
|
/// sub-keys) and `language:` are handled with a simple indentation stack.
|
|
/// This avoids pulling a full YAML parser dep into the scaffold.
|
|
fn yaml_to_json(yaml: &str) -> Result<String> {
|
|
let mut out = String::from("{");
|
|
let lines: Vec<&str> = yaml.lines().collect();
|
|
let mut i = 0;
|
|
while i < lines.len() {
|
|
let line = lines[i].trim_end();
|
|
if line.trim().is_empty() || line.trim_start().starts_with('#') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let (key, rest) = match line.split_once(':') {
|
|
Some((k, v)) => (k.trim(), v.trim()),
|
|
None => {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
};
|
|
if rest.is_empty() {
|
|
// Could be a block array (`- item`) or a nested object.
|
|
// Peek ahead: collect comment + dash lines as array items.
|
|
let mut items: Vec<String> = Vec::new();
|
|
let mut j = i + 1;
|
|
while j < lines.len() {
|
|
let l = lines[j].trim();
|
|
if l.is_empty() {
|
|
j += 1;
|
|
continue;
|
|
}
|
|
// Skip comment lines inside a block array.
|
|
if l.starts_with('#') {
|
|
j += 1;
|
|
continue;
|
|
}
|
|
if !l.starts_with('-') {
|
|
break;
|
|
}
|
|
let val = l.trim_start_matches('-').trim();
|
|
items.push(quote_or_passthrough(val));
|
|
j += 1;
|
|
}
|
|
if !items.is_empty() {
|
|
out.push_str(&format!("\"{key}\":[{}],", items.join(",")));
|
|
i = j;
|
|
continue;
|
|
}
|
|
// Nested object: collect indented lines until dedent.
|
|
let mut sub = String::from("{");
|
|
while j < lines.len() {
|
|
let l = lines[j];
|
|
if !l.starts_with(' ') && !l.starts_with('\t') {
|
|
break;
|
|
}
|
|
if let Some((sk, sv)) = l.trim().split_once(':') {
|
|
sub.push_str(&format!(
|
|
"\"{}\":{},",
|
|
sk.trim(),
|
|
quote_or_passthrough(sv.trim())
|
|
));
|
|
}
|
|
j += 1;
|
|
}
|
|
sub.pop(); // trailing comma
|
|
sub.push('}');
|
|
out.push_str(&format!("\"{key}\":{sub},"));
|
|
i = j;
|
|
continue;
|
|
}
|
|
// Inline array? `[a, b, c]`
|
|
if rest.starts_with('[') {
|
|
let inner = rest
|
|
.trim_start_matches('[')
|
|
.trim_end_matches(']')
|
|
.split(',')
|
|
.map(|s| quote_or_passthrough(s.trim()))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
out.push_str(&format!("\"{key}\":[{inner}],"));
|
|
} else {
|
|
out.push_str(&format!(
|
|
"\"{key}\":{},",
|
|
quote_or_passthrough(rest)
|
|
));
|
|
}
|
|
i += 1;
|
|
}
|
|
if out.ends_with(',') {
|
|
out.pop();
|
|
}
|
|
out.push('}');
|
|
Ok(out)
|
|
}
|
|
|
|
/// Quotes a value as a JSON string unless it looks like a number or bool.
|
|
fn quote_or_passthrough(v: &str) -> String {
|
|
let v = v.trim();
|
|
if v.is_empty() {
|
|
return "\"\"".into();
|
|
}
|
|
if v == "true" || v == "false" || v == "null" {
|
|
return v.into();
|
|
}
|
|
if v.parse::<f64>().is_ok() {
|
|
return v.into();
|
|
}
|
|
// Strip surrounding quotes if present, then re-add as JSON string.
|
|
let v = v.trim_matches('"');
|
|
format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\""))
|
|
}
|