Skip to main content

harn_modules/
personas.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
9pub struct PersonaManifestDocument {
10    #[serde(default)]
11    pub personas: Vec<PersonaManifestEntry>,
12}
13
14#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15pub struct PersonaManifestEntry {
16    #[serde(default)]
17    pub name: Option<String>,
18    #[serde(default)]
19    pub version: Option<String>,
20    #[serde(default)]
21    pub description: Option<String>,
22    #[serde(default, alias = "entry", alias = "entry_pipeline")]
23    pub entry_workflow: Option<String>,
24    #[serde(default)]
25    pub tools: Vec<String>,
26    #[serde(default)]
27    pub capabilities: Vec<String>,
28    #[serde(default, alias = "tier", alias = "autonomy")]
29    pub autonomy_tier: Option<PersonaAutonomyTier>,
30    #[serde(default, alias = "receipts")]
31    pub receipt_policy: Option<PersonaReceiptPolicy>,
32    #[serde(default)]
33    pub triggers: Vec<String>,
34    #[serde(default)]
35    pub schedules: Vec<String>,
36    #[serde(default)]
37    pub model_policy: PersonaModelPolicy,
38    #[serde(default)]
39    pub budget: PersonaBudget,
40    #[serde(default)]
41    pub handoffs: Vec<String>,
42    #[serde(default)]
43    pub context_packs: Vec<String>,
44    #[serde(default, alias = "eval_packs")]
45    pub evals: Vec<String>,
46    #[serde(default)]
47    pub owner: Option<String>,
48    #[serde(default)]
49    pub package_source: PersonaPackageSource,
50    #[serde(default)]
51    pub rollout_policy: PersonaRolloutPolicy,
52    #[serde(flatten, default)]
53    pub extra: BTreeMap<String, toml::Value>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum PersonaAutonomyTier {
59    Shadow,
60    Suggest,
61    ActWithApproval,
62    ActAuto,
63}
64
65impl PersonaAutonomyTier {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Shadow => "shadow",
69            Self::Suggest => "suggest",
70            Self::ActWithApproval => "act_with_approval",
71            Self::ActAuto => "act_auto",
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum PersonaReceiptPolicy {
79    #[default]
80    Optional,
81    Required,
82    Disabled,
83}
84
85impl PersonaReceiptPolicy {
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::Optional => "optional",
89            Self::Required => "required",
90            Self::Disabled => "disabled",
91        }
92    }
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96pub struct PersonaModelPolicy {
97    #[serde(default)]
98    pub default_model: Option<String>,
99    #[serde(default)]
100    pub escalation_model: Option<String>,
101    #[serde(default)]
102    pub fallback_models: Vec<String>,
103    #[serde(default)]
104    pub reasoning_effort: Option<String>,
105    #[serde(flatten, default)]
106    pub extra: BTreeMap<String, toml::Value>,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110pub struct PersonaBudget {
111    #[serde(default)]
112    pub daily_usd: Option<f64>,
113    #[serde(default)]
114    pub hourly_usd: Option<f64>,
115    #[serde(default)]
116    pub run_usd: Option<f64>,
117    #[serde(default)]
118    pub frontier_escalations: Option<u32>,
119    #[serde(default)]
120    pub max_tokens: Option<u64>,
121    #[serde(default)]
122    pub max_runtime_seconds: Option<u64>,
123    #[serde(flatten, default)]
124    pub extra: BTreeMap<String, toml::Value>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
128pub struct PersonaPackageSource {
129    #[serde(default)]
130    pub package: Option<String>,
131    #[serde(default)]
132    pub path: Option<String>,
133    #[serde(default)]
134    pub git: Option<String>,
135    #[serde(default)]
136    pub rev: Option<String>,
137    #[serde(flatten, default)]
138    pub extra: BTreeMap<String, toml::Value>,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142pub struct PersonaRolloutPolicy {
143    #[serde(default)]
144    pub mode: Option<String>,
145    #[serde(default)]
146    pub percentage: Option<u8>,
147    #[serde(default)]
148    pub cohorts: Vec<String>,
149    #[serde(flatten, default)]
150    pub extra: BTreeMap<String, toml::Value>,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize)]
154pub struct ResolvedPersonaManifest {
155    pub manifest_path: PathBuf,
156    pub manifest_dir: PathBuf,
157    pub personas: Vec<PersonaManifestEntry>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct PersonaValidationError {
162    pub manifest_path: PathBuf,
163    pub field_path: String,
164    pub message: String,
165}
166
167impl std::fmt::Display for PersonaValidationError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        write!(
170            f,
171            "{} {}: {}",
172            self.manifest_path.display(),
173            self.field_path,
174            self.message
175        )
176    }
177}
178
179impl std::error::Error for PersonaValidationError {}
180
181#[derive(Debug, Clone, Default)]
182pub struct PersonaValidationContext {
183    pub known_capabilities: BTreeSet<String>,
184    pub known_tools: BTreeSet<String>,
185    pub known_names: BTreeSet<String>,
186}
187
188pub fn parse_persona_manifest_str(
189    source: &str,
190) -> Result<PersonaManifestDocument, toml::de::Error> {
191    let document = toml::from_str::<PersonaManifestDocument>(source)?;
192    if !document.personas.is_empty() {
193        return Ok(document);
194    }
195    let entry = toml::from_str::<PersonaManifestEntry>(source)?;
196    if entry.name.is_some()
197        || entry.description.is_some()
198        || entry.entry_workflow.is_some()
199        || !entry.tools.is_empty()
200        || !entry.capabilities.is_empty()
201    {
202        Ok(PersonaManifestDocument {
203            personas: vec![entry],
204        })
205    } else {
206        Ok(document)
207    }
208}
209
210pub fn parse_persona_manifest_file(path: &Path) -> Result<PersonaManifestDocument, String> {
211    let content = fs::read_to_string(path)
212        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
213    parse_persona_manifest_str(&content)
214        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
215}
216
217pub fn validate_persona_manifests(
218    manifest_path: &Path,
219    personas: &[PersonaManifestEntry],
220    context: &PersonaValidationContext,
221) -> Result<(), Vec<PersonaValidationError>> {
222    let mut errors = Vec::new();
223    for (index, persona) in personas.iter().enumerate() {
224        validate_persona(persona, index, manifest_path, context, &mut errors);
225    }
226    if errors.is_empty() {
227        Ok(())
228    } else {
229        Err(errors)
230    }
231}
232
233pub fn validate_persona(
234    persona: &PersonaManifestEntry,
235    index: usize,
236    manifest_path: &Path,
237    context: &PersonaValidationContext,
238    errors: &mut Vec<PersonaValidationError>,
239) {
240    let root = format!("[[personas]][{index}]");
241    for field in persona.extra.keys() {
242        persona_error(
243            manifest_path,
244            format!("{root}.{field}"),
245            "unknown persona field",
246            errors,
247        );
248    }
249    let name = validate_required_string(
250        manifest_path,
251        &root,
252        "name",
253        persona.name.as_deref(),
254        errors,
255    );
256    if let Some(name) = name {
257        validate_tokenish(manifest_path, &root, "name", name, errors);
258    }
259    validate_required_string(
260        manifest_path,
261        &root,
262        "description",
263        persona.description.as_deref(),
264        errors,
265    );
266    validate_required_string(
267        manifest_path,
268        &root,
269        "entry_workflow",
270        persona.entry_workflow.as_deref(),
271        errors,
272    );
273    if persona.tools.is_empty() && persona.capabilities.is_empty() {
274        persona_error(
275            manifest_path,
276            format!("{root}.tools"),
277            "persona requires at least one tool or capability",
278            errors,
279        );
280    }
281    if persona.autonomy_tier.is_none() {
282        persona_error(
283            manifest_path,
284            format!("{root}.autonomy_tier"),
285            "missing required autonomy tier",
286            errors,
287        );
288    }
289    if persona.receipt_policy.is_none() {
290        persona_error(
291            manifest_path,
292            format!("{root}.receipt_policy"),
293            "missing required receipt policy",
294            errors,
295        );
296    }
297    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
298    for tool in &persona.tools {
299        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
300            persona_error(
301                manifest_path,
302                format!("{root}.tools"),
303                format!("unknown tool '{tool}'"),
304                errors,
305            );
306        }
307    }
308    for capability in &persona.capabilities {
309        let Some((cap, op)) = capability.split_once('.') else {
310            persona_error(
311                manifest_path,
312                format!("{root}.capabilities"),
313                format!("capability '{capability}' must use capability.operation syntax"),
314                errors,
315            );
316            continue;
317        };
318        if cap.trim().is_empty() || op.trim().is_empty() {
319            persona_error(
320                manifest_path,
321                format!("{root}.capabilities"),
322                format!("capability '{capability}' must use capability.operation syntax"),
323                errors,
324            );
325        } else if !context.known_capabilities.is_empty()
326            && !context.known_capabilities.contains(capability)
327        {
328            persona_error(
329                manifest_path,
330                format!("{root}.capabilities"),
331                format!("unknown capability '{capability}'"),
332                errors,
333            );
334        }
335    }
336    validate_string_list(
337        manifest_path,
338        &root,
339        "context_packs",
340        &persona.context_packs,
341        errors,
342    );
343    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
344    for schedule in &persona.schedules {
345        if schedule.trim().is_empty() {
346            persona_error(
347                manifest_path,
348                format!("{root}.schedules"),
349                "schedule entries must not be empty",
350                errors,
351            );
352        } else if let Err(error) = croner::Cron::from_str(schedule) {
353            persona_error(
354                manifest_path,
355                format!("{root}.schedules"),
356                format!("invalid cron schedule '{schedule}': {error}"),
357                errors,
358            );
359        }
360    }
361    for trigger in &persona.triggers {
362        match trigger.split_once('.') {
363            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
364            _ => persona_error(
365                manifest_path,
366                format!("{root}.triggers"),
367                format!("trigger '{trigger}' must use provider.event syntax"),
368                errors,
369            ),
370        }
371    }
372    for handoff in &persona.handoffs {
373        if !context.known_names.contains(handoff) {
374            persona_error(
375                manifest_path,
376                format!("{root}.handoffs"),
377                format!("unknown handoff target '{handoff}'"),
378                errors,
379            );
380        }
381    }
382    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
383    validate_persona_nested_extra(
384        manifest_path,
385        &root,
386        "model_policy",
387        &persona.model_policy.extra,
388        errors,
389    );
390    validate_persona_nested_extra(
391        manifest_path,
392        &root,
393        "package_source",
394        &persona.package_source.extra,
395        errors,
396    );
397    validate_persona_nested_extra(
398        manifest_path,
399        &root,
400        "rollout_policy",
401        &persona.rollout_policy.extra,
402        errors,
403    );
404    if let Some(percentage) = persona.rollout_policy.percentage {
405        if percentage > 100 {
406            persona_error(
407                manifest_path,
408                format!("{root}.rollout_policy.percentage"),
409                "rollout percentage must be between 0 and 100",
410                errors,
411            );
412        }
413    }
414}
415
416pub fn validate_required_string<'a>(
417    manifest_path: &Path,
418    root: &str,
419    field: &str,
420    value: Option<&'a str>,
421    errors: &mut Vec<PersonaValidationError>,
422) -> Option<&'a str> {
423    match value.map(str::trim) {
424        Some(value) if !value.is_empty() => Some(value),
425        _ => {
426            persona_error(
427                manifest_path,
428                format!("{root}.{field}"),
429                format!("missing required {field}"),
430                errors,
431            );
432            None
433        }
434    }
435}
436
437pub fn validate_string_list(
438    manifest_path: &Path,
439    root: &str,
440    field: &str,
441    values: &[String],
442    errors: &mut Vec<PersonaValidationError>,
443) {
444    for value in values {
445        if value.trim().is_empty() {
446            persona_error(
447                manifest_path,
448                format!("{root}.{field}"),
449                format!("{field} entries must not be empty"),
450                errors,
451            );
452        } else {
453            validate_tokenish(manifest_path, root, field, value, errors);
454        }
455    }
456}
457
458pub fn validate_tokenish(
459    manifest_path: &Path,
460    root: &str,
461    field: &str,
462    value: &str,
463    errors: &mut Vec<PersonaValidationError>,
464) {
465    if !value
466        .chars()
467        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
468    {
469        persona_error(
470            manifest_path,
471            format!("{root}.{field}"),
472            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
473            errors,
474        );
475    }
476}
477
478pub fn validate_persona_budget(
479    manifest_path: &Path,
480    root: &str,
481    budget: &PersonaBudget,
482    errors: &mut Vec<PersonaValidationError>,
483) {
484    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
485    for (field, value) in [
486        ("daily_usd", budget.daily_usd),
487        ("hourly_usd", budget.hourly_usd),
488        ("run_usd", budget.run_usd),
489    ] {
490        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
491            persona_error(
492                manifest_path,
493                format!("{root}.budget.{field}"),
494                "budget amounts must be finite non-negative numbers",
495                errors,
496            );
497        }
498    }
499}
500
501pub fn validate_persona_nested_extra(
502    manifest_path: &Path,
503    root: &str,
504    field: &str,
505    extra: &BTreeMap<String, toml::Value>,
506    errors: &mut Vec<PersonaValidationError>,
507) {
508    for key in extra.keys() {
509        persona_error(
510            manifest_path,
511            format!("{root}.{field}.{key}"),
512            format!("unknown {field} field"),
513            errors,
514        );
515    }
516}
517
518pub fn persona_error(
519    manifest_path: &Path,
520    field_path: String,
521    message: impl Into<String>,
522    errors: &mut Vec<PersonaValidationError>,
523) {
524    errors.push(PersonaValidationError {
525        manifest_path: manifest_path.to_path_buf(),
526        field_path,
527        message: message.into(),
528    });
529}
530
531pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
532    BTreeMap::from([
533        (
534            "workspace",
535            vec![
536                "read_text",
537                "write_text",
538                "apply_edit",
539                "delete",
540                "exists",
541                "file_exists",
542                "list",
543                "project_root",
544                "roots",
545            ],
546        ),
547        ("process", vec!["exec"]),
548        ("template", vec!["render"]),
549        ("interaction", vec!["ask"]),
550        (
551            "runtime",
552            vec![
553                "approved_plan",
554                "dry_run",
555                "pipeline_input",
556                "record_run",
557                "set_result",
558                "task",
559            ],
560        ),
561        (
562            "project",
563            vec![
564                "agent_instructions",
565                "code_patterns",
566                "compute_content_hash",
567                "ide_context",
568                "lessons",
569                "mcp_config",
570                "metadata_get",
571                "metadata_refresh_hashes",
572                "metadata_save",
573                "metadata_set",
574                "metadata_stale",
575                "scan",
576                "scope_test_command",
577                "test_commands",
578            ],
579        ),
580        (
581            "session",
582            vec![
583                "active_roots",
584                "changed_paths",
585                "preread_get",
586                "preread_read_many",
587            ],
588        ),
589        (
590            "editor",
591            vec!["get_active_file", "get_selection", "get_visible_files"],
592        ),
593        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
594        ("git", vec!["get_branch", "get_diff"]),
595        ("learning", vec!["get_learned_rules", "report_correction"]),
596    ])
597}
598
599pub fn default_persona_capabilities() -> BTreeSet<String> {
600    let mut capabilities = BTreeSet::new();
601    for (capability, operations) in default_persona_capability_map() {
602        for operation in operations {
603            capabilities.insert(format!("{capability}.{operation}"));
604        }
605    }
606    capabilities
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    fn context(names: &[&str]) -> PersonaValidationContext {
614        PersonaValidationContext {
615            known_capabilities: default_persona_capabilities(),
616            known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
617            known_names: names.iter().map(|name| name.to_string()).collect(),
618        }
619    }
620
621    #[test]
622    fn validates_sample_manifest() {
623        let parsed = parse_persona_manifest_str(
624            r#"
625[[personas]]
626name = "merge_captain"
627description = "Owns PR readiness."
628entry_workflow = "workflows/merge_captain.harn#run"
629tools = ["github", "ci"]
630capabilities = ["git.get_diff"]
631autonomy = "act_with_approval"
632receipts = "required"
633triggers = ["github.pr_opened"]
634schedules = ["*/30 * * * *"]
635handoffs = ["review_captain"]
636context_packs = ["repo_policy"]
637evals = ["merge_safety"]
638budget = { daily_usd = 20.0 }
639
640[[personas]]
641name = "review_captain"
642description = "Reviews code."
643entry_workflow = "workflows/review_captain.harn#run"
644tools = ["github"]
645autonomy_tier = "suggest"
646receipt_policy = "optional"
647"#,
648        )
649        .expect("manifest parses");
650
651        validate_persona_manifests(
652            Path::new("harn.toml"),
653            &parsed.personas,
654            &context(&["merge_captain", "review_captain"]),
655        )
656        .expect("manifest validates");
657    }
658
659    #[test]
660    fn bad_manifest_produces_typed_errors() {
661        let parsed = parse_persona_manifest_str(
662            r#"
663[[personas]]
664name = "bad"
665description = ""
666entry_workflow = ""
667tools = ["unknown"]
668capabilities = ["git"]
669autonomy = "shadow"
670receipts = "required"
671triggers = ["github"]
672schedules = [""]
673handoffs = ["missing"]
674budget = { daily_usd = -1.0, surprise = true }
675surprise = true
676"#,
677        )
678        .expect("manifest parses");
679
680        let errors = validate_persona_manifests(
681            Path::new("harn.toml"),
682            &parsed.personas,
683            &context(&["bad"]),
684        )
685        .expect_err("manifest rejects");
686        let fields: BTreeSet<_> = errors
687            .iter()
688            .map(|error| error.field_path.as_str())
689            .collect();
690        assert!(fields.contains("[[personas]][0].description"));
691        assert!(fields.contains("[[personas]][0].entry_workflow"));
692        assert!(fields.contains("[[personas]][0].tools"));
693        assert!(fields.contains("[[personas]][0].capabilities"));
694        assert!(fields.contains("[[personas]][0].triggers"));
695        assert!(fields.contains("[[personas]][0].schedules"));
696        assert!(fields.contains("[[personas]][0].handoffs"));
697        assert!(fields.contains("[[personas]][0].budget.daily_usd"));
698        assert!(fields.contains("[[personas]][0].budget.surprise"));
699        assert!(fields.contains("[[personas]][0].surprise"));
700    }
701}