Skip to main content

harn_modules/
personas.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use harn_parser::{Attribute, DictEntry, Node, SNode};
6use serde::{Deserialize, Serialize};
7use std::str::FromStr;
8
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
10pub struct PersonaManifestDocument {
11    #[serde(default)]
12    pub personas: Vec<PersonaManifestEntry>,
13}
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct PersonaManifestEntry {
17    #[serde(default)]
18    pub name: Option<String>,
19    #[serde(default)]
20    pub version: Option<String>,
21    #[serde(default)]
22    pub description: Option<String>,
23    #[serde(default, alias = "entry", alias = "entry_pipeline")]
24    pub entry_workflow: Option<String>,
25    #[serde(default)]
26    pub tools: Vec<String>,
27    #[serde(default)]
28    pub capabilities: Vec<String>,
29    #[serde(default, alias = "tier", alias = "autonomy")]
30    pub autonomy_tier: Option<PersonaAutonomyTier>,
31    #[serde(default, alias = "receipts")]
32    pub receipt_policy: Option<PersonaReceiptPolicy>,
33    #[serde(default)]
34    pub triggers: Vec<String>,
35    #[serde(default)]
36    pub schedules: Vec<String>,
37    #[serde(default)]
38    pub model_policy: PersonaModelPolicy,
39    #[serde(default)]
40    pub budget: PersonaBudget,
41    #[serde(default)]
42    pub handoffs: Vec<String>,
43    #[serde(default)]
44    pub context_packs: Vec<String>,
45    #[serde(default, alias = "eval_packs")]
46    pub evals: Vec<String>,
47    #[serde(default)]
48    pub owner: Option<String>,
49    #[serde(default)]
50    pub package_source: PersonaPackageSource,
51    #[serde(default)]
52    pub rollout_policy: PersonaRolloutPolicy,
53    #[serde(default)]
54    pub steps: Vec<PersonaStepMetadata>,
55    #[serde(flatten, default)]
56    pub extra: BTreeMap<String, toml::Value>,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
60pub struct PersonaStepMetadata {
61    pub name: String,
62    pub function: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub model: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub approval: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub receipt: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub error_boundary: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub retry: Option<PersonaStepRetry>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub budget: Option<PersonaStepBudget>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub line: Option<usize>,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
80pub struct PersonaStepRetry {
81    pub max_attempts: u64,
82}
83
84/// Per-step token / cost ceiling. Either field is optional; whichever is
85/// set governs that dimension. Surfaced statically by `harn persona
86/// inspect --json` and consumed at runtime by `crates/harn-vm/src/step_runtime.rs`
87/// to short-circuit `llm_call` invocations before they exceed the limit.
88#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct PersonaStepBudget {
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub max_tokens: Option<u64>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub max_usd: Option<f64>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum PersonaAutonomyTier {
99    Shadow,
100    Suggest,
101    ActWithApproval,
102    ActAuto,
103}
104
105impl PersonaAutonomyTier {
106    pub fn as_str(self) -> &'static str {
107        match self {
108            Self::Shadow => "shadow",
109            Self::Suggest => "suggest",
110            Self::ActWithApproval => "act_with_approval",
111            Self::ActAuto => "act_auto",
112        }
113    }
114}
115
116impl FromStr for PersonaAutonomyTier {
117    type Err = ();
118
119    fn from_str(value: &str) -> Result<Self, Self::Err> {
120        match value {
121            "shadow" => Ok(Self::Shadow),
122            "suggest" => Ok(Self::Suggest),
123            "act_with_approval" => Ok(Self::ActWithApproval),
124            "act_auto" => Ok(Self::ActAuto),
125            _ => Err(()),
126        }
127    }
128}
129
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum PersonaReceiptPolicy {
133    #[default]
134    Optional,
135    Required,
136    Disabled,
137}
138
139impl PersonaReceiptPolicy {
140    pub fn as_str(self) -> &'static str {
141        match self {
142            Self::Optional => "optional",
143            Self::Required => "required",
144            Self::Disabled => "disabled",
145        }
146    }
147}
148
149impl FromStr for PersonaReceiptPolicy {
150    type Err = ();
151
152    fn from_str(value: &str) -> Result<Self, Self::Err> {
153        match value {
154            "optional" => Ok(Self::Optional),
155            "required" => Ok(Self::Required),
156            "disabled" => Ok(Self::Disabled),
157            "none" => Ok(Self::Disabled),
158            _ => Err(()),
159        }
160    }
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
164pub struct PersonaModelPolicy {
165    #[serde(default)]
166    pub default_model: Option<String>,
167    #[serde(default)]
168    pub escalation_model: Option<String>,
169    #[serde(default)]
170    pub fallback_models: Vec<String>,
171    #[serde(default)]
172    pub reasoning_effort: Option<String>,
173    #[serde(flatten, default)]
174    pub extra: BTreeMap<String, toml::Value>,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
178pub struct PersonaBudget {
179    #[serde(default)]
180    pub daily_usd: Option<f64>,
181    #[serde(default)]
182    pub hourly_usd: Option<f64>,
183    #[serde(default)]
184    pub run_usd: Option<f64>,
185    #[serde(default)]
186    pub frontier_escalations: Option<u32>,
187    #[serde(default)]
188    pub max_tokens: Option<u64>,
189    #[serde(default)]
190    pub max_runtime_seconds: Option<u64>,
191    #[serde(flatten, default)]
192    pub extra: BTreeMap<String, toml::Value>,
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
196pub struct PersonaPackageSource {
197    #[serde(default)]
198    pub package: Option<String>,
199    #[serde(default)]
200    pub path: Option<String>,
201    #[serde(default)]
202    pub git: Option<String>,
203    #[serde(default)]
204    pub rev: Option<String>,
205    #[serde(flatten, default)]
206    pub extra: BTreeMap<String, toml::Value>,
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210pub struct PersonaRolloutPolicy {
211    #[serde(default)]
212    pub mode: Option<String>,
213    #[serde(default)]
214    pub percentage: Option<u8>,
215    #[serde(default)]
216    pub cohorts: Vec<String>,
217    #[serde(flatten, default)]
218    pub extra: BTreeMap<String, toml::Value>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize)]
222pub struct ResolvedPersonaManifest {
223    pub manifest_path: PathBuf,
224    pub manifest_dir: PathBuf,
225    pub personas: Vec<PersonaManifestEntry>,
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize)]
229pub struct PersonaValidationError {
230    pub manifest_path: PathBuf,
231    pub field_path: String,
232    pub message: String,
233}
234
235impl std::fmt::Display for PersonaValidationError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        write!(
238            f,
239            "{} {}: {}",
240            self.manifest_path.display(),
241            self.field_path,
242            self.message
243        )
244    }
245}
246
247impl std::error::Error for PersonaValidationError {}
248
249#[derive(Debug, Clone, Default)]
250pub struct PersonaValidationContext {
251    pub known_capabilities: BTreeSet<String>,
252    pub known_tools: BTreeSet<String>,
253    pub known_names: BTreeSet<String>,
254}
255
256pub fn parse_persona_manifest_str(
257    source: &str,
258) -> Result<PersonaManifestDocument, toml::de::Error> {
259    let document = toml::from_str::<PersonaManifestDocument>(source)?;
260    if !document.personas.is_empty() {
261        return Ok(document);
262    }
263    let entry = toml::from_str::<PersonaManifestEntry>(source)?;
264    if entry.name.is_some()
265        || entry.description.is_some()
266        || entry.entry_workflow.is_some()
267        || !entry.tools.is_empty()
268        || !entry.capabilities.is_empty()
269    {
270        Ok(PersonaManifestDocument {
271            personas: vec![entry],
272        })
273    } else {
274        Ok(document)
275    }
276}
277
278pub fn parse_persona_manifest_file(path: &Path) -> Result<PersonaManifestDocument, String> {
279    let content = fs::read_to_string(path)
280        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
281    parse_persona_manifest_str(&content)
282        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
283}
284
285pub fn parse_persona_source_file(path: &Path) -> Result<PersonaManifestDocument, String> {
286    let content = fs::read_to_string(path)
287        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
288    parse_persona_source_str(&content)
289        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
290}
291
292pub fn parse_persona_source_str(source: &str) -> Result<PersonaManifestDocument, String> {
293    let program = harn_parser::parse_source(source).map_err(|error| error.to_string())?;
294    Ok(extract_personas_from_program(&program))
295}
296
297pub fn extract_personas_from_program(program: &[SNode]) -> PersonaManifestDocument {
298    let step_decls = collect_step_declarations(program);
299    let mut personas = Vec::new();
300    for snode in program {
301        let Node::AttributedDecl { attributes, inner } = &snode.node else {
302            continue;
303        };
304        let Some(persona_attr) = attributes.iter().find(|attr| attr.name == "persona") else {
305            continue;
306        };
307        let Node::FnDecl { name, body, .. } = &inner.node else {
308            continue;
309        };
310        let persona_name = attr_string(persona_attr, "name").unwrap_or_else(|| name.clone());
311        let mut seen = BTreeSet::new();
312        let mut steps = Vec::new();
313        for call_name in collect_called_functions(body) {
314            if !seen.insert(call_name.clone()) {
315                continue;
316            }
317            if let Some(step) = step_decls.get(&call_name) {
318                steps.push(step.clone());
319            }
320        }
321        personas.push(PersonaManifestEntry {
322            name: Some(persona_name),
323            description: Some(
324                attr_string(persona_attr, "description")
325                    .unwrap_or_else(|| "Source-declared persona".to_string()),
326            ),
327            entry_workflow: Some(name.clone()),
328            tools: attr_string_list(persona_attr, "tools"),
329            capabilities: {
330                let capabilities = attr_string_list(persona_attr, "capabilities");
331                if capabilities.is_empty() {
332                    vec!["project.test_commands".to_string()]
333                } else {
334                    capabilities
335                }
336            },
337            autonomy_tier: attr_string(persona_attr, "autonomy")
338                .as_deref()
339                .and_then(|value| PersonaAutonomyTier::from_str(value).ok())
340                .or(Some(PersonaAutonomyTier::Suggest)),
341            receipt_policy: attr_string(persona_attr, "receipts")
342                .as_deref()
343                .and_then(|value| PersonaReceiptPolicy::from_str(value).ok())
344                .or(Some(PersonaReceiptPolicy::Optional)),
345            steps,
346            ..PersonaManifestEntry::default()
347        });
348    }
349    PersonaManifestDocument { personas }
350}
351
352pub fn extract_step_metadata_from_program(program: &[SNode]) -> Vec<PersonaStepMetadata> {
353    collect_step_declarations(program).into_values().collect()
354}
355
356fn collect_step_declarations(program: &[SNode]) -> BTreeMap<String, PersonaStepMetadata> {
357    let mut steps = BTreeMap::new();
358    for snode in program {
359        let Node::AttributedDecl { attributes, inner } = &snode.node else {
360            continue;
361        };
362        let Some(step_attr) = attributes.iter().find(|attr| attr.name == "step") else {
363            continue;
364        };
365        let Node::FnDecl { name, .. } = &inner.node else {
366            continue;
367        };
368        steps.insert(
369            name.clone(),
370            PersonaStepMetadata {
371                name: attr_string(step_attr, "name").unwrap_or_else(|| name.clone()),
372                function: name.clone(),
373                model: attr_string(step_attr, "model"),
374                approval: attr_string(step_attr, "approval"),
375                receipt: attr_string(step_attr, "receipt"),
376                error_boundary: attr_string(step_attr, "error_boundary"),
377                retry: attr_retry(step_attr),
378                budget: attr_step_budget(step_attr),
379                line: Some(inner.span.line),
380            },
381        );
382    }
383    steps
384}
385
386fn attr_string(attr: &Attribute, key: &str) -> Option<String> {
387    attr.named_arg(key).and_then(node_string)
388}
389
390fn attr_string_list(attr: &Attribute, key: &str) -> Vec<String> {
391    let Some(value) = attr.named_arg(key) else {
392        return Vec::new();
393    };
394    let Node::ListLiteral(items) = &value.node else {
395        return Vec::new();
396    };
397    items.iter().filter_map(node_string).collect()
398}
399
400fn node_string(node: &SNode) -> Option<String> {
401    match &node.node {
402        Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
403            Some(value.clone())
404        }
405        _ => None,
406    }
407}
408
409fn attr_retry(attr: &Attribute) -> Option<PersonaStepRetry> {
410    let retry = attr.named_arg("retry")?;
411    let Node::DictLiteral(entries) = &retry.node else {
412        return None;
413    };
414    for entry in entries {
415        if entry_key(&entry.key) == Some("max_attempts") {
416            if let Node::IntLiteral(value) = entry.value.node {
417                if value >= 1 {
418                    return Some(PersonaStepRetry {
419                        max_attempts: value as u64,
420                    });
421                }
422            }
423        }
424    }
425    None
426}
427
428fn attr_step_budget(attr: &Attribute) -> Option<PersonaStepBudget> {
429    let budget = attr.named_arg("budget")?;
430    let Node::DictLiteral(entries) = &budget.node else {
431        return None;
432    };
433    let mut out = PersonaStepBudget::default();
434    let mut any = false;
435    for entry in entries {
436        match entry_key(&entry.key) {
437            Some("max_tokens") => {
438                if let Node::IntLiteral(value) = entry.value.node {
439                    if value >= 1 {
440                        out.max_tokens = Some(value as u64);
441                        any = true;
442                    }
443                }
444            }
445            Some("max_usd") => match entry.value.node {
446                Node::FloatLiteral(value) if value.is_finite() && value >= 0.0 => {
447                    out.max_usd = Some(value);
448                    any = true;
449                }
450                Node::IntLiteral(value) if value >= 0 => {
451                    out.max_usd = Some(value as f64);
452                    any = true;
453                }
454                _ => {}
455            },
456            _ => {}
457        }
458    }
459    any.then_some(out)
460}
461
462fn entry_key(node: &SNode) -> Option<&str> {
463    match &node.node {
464        Node::Identifier(value) | Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
465            Some(value.as_str())
466        }
467        _ => None,
468    }
469}
470
471fn collect_called_functions(body: &[SNode]) -> Vec<String> {
472    let mut calls = Vec::new();
473    for node in body {
474        collect_called_functions_node(node, &mut calls);
475    }
476    calls
477}
478
479fn collect_called_functions_node(node: &SNode, calls: &mut Vec<String>) {
480    match &node.node {
481        Node::FunctionCall { name, args, .. } => {
482            calls.push(name.clone());
483            collect_many(args, calls);
484        }
485        Node::LetBinding { value, .. }
486        | Node::VarBinding { value, .. }
487        | Node::ReturnStmt { value: Some(value) }
488        | Node::YieldExpr { value: Some(value) }
489        | Node::EmitExpr { value }
490        | Node::ThrowStmt { value }
491        | Node::Spread(value)
492        | Node::TryOperator { operand: value }
493        | Node::TryStar { operand: value }
494        | Node::UnaryOp { operand: value, .. } => collect_called_functions_node(value, calls),
495        Node::IfElse {
496            condition,
497            then_body,
498            else_body,
499        } => {
500            collect_called_functions_node(condition, calls);
501            collect_many(then_body, calls);
502            if let Some(else_body) = else_body {
503                collect_many(else_body, calls);
504            }
505        }
506        Node::ForIn { iterable, body, .. } => {
507            collect_called_functions_node(iterable, calls);
508            collect_many(body, calls);
509        }
510        Node::MatchExpr { value, arms } => {
511            collect_called_functions_node(value, calls);
512            for arm in arms {
513                collect_called_functions_node(&arm.pattern, calls);
514                if let Some(guard) = &arm.guard {
515                    collect_called_functions_node(guard, calls);
516                }
517                collect_many(&arm.body, calls);
518            }
519        }
520        Node::WhileLoop { condition, body } => {
521            collect_called_functions_node(condition, calls);
522            collect_many(body, calls);
523        }
524        Node::Retry { count, body } => {
525            collect_called_functions_node(count, calls);
526            collect_many(body, calls);
527        }
528        Node::CostRoute { options, body } => {
529            for (_, value) in options {
530                collect_called_functions_node(value, calls);
531            }
532            collect_many(body, calls);
533        }
534        Node::TryCatch {
535            has_catch: _,
536            body,
537            catch_body,
538            finally_body,
539            ..
540        } => {
541            collect_many(body, calls);
542            collect_many(catch_body, calls);
543            if let Some(finally_body) = finally_body {
544                collect_many(finally_body, calls);
545            }
546        }
547        Node::TryExpr { body }
548        | Node::SpawnExpr { body }
549        | Node::DeferStmt { body }
550        | Node::MutexBlock { body }
551        | Node::Block(body)
552        | Node::Closure { body, .. } => collect_many(body, calls),
553        Node::DeadlineBlock { duration, body } => {
554            collect_called_functions_node(duration, calls);
555            collect_many(body, calls);
556        }
557        Node::GuardStmt {
558            condition,
559            else_body,
560        } => {
561            collect_called_functions_node(condition, calls);
562            collect_many(else_body, calls);
563        }
564        Node::RequireStmt { condition, message } => {
565            collect_called_functions_node(condition, calls);
566            if let Some(message) = message {
567                collect_called_functions_node(message, calls);
568            }
569        }
570        Node::Parallel {
571            expr,
572            body,
573            options,
574            ..
575        } => {
576            collect_called_functions_node(expr, calls);
577            for (_, value) in options {
578                collect_called_functions_node(value, calls);
579            }
580            collect_many(body, calls);
581        }
582        Node::SelectExpr {
583            cases,
584            timeout,
585            default_body,
586        } => {
587            for case in cases {
588                collect_called_functions_node(&case.channel, calls);
589                collect_many(&case.body, calls);
590            }
591            if let Some((duration, body)) = timeout {
592                collect_called_functions_node(duration, calls);
593                collect_many(body, calls);
594            }
595            if let Some(body) = default_body {
596                collect_many(body, calls);
597            }
598        }
599        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
600            collect_called_functions_node(object, calls);
601            collect_many(args, calls);
602        }
603        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
604            collect_called_functions_node(object, calls);
605        }
606        Node::SubscriptAccess { object, index }
607        | Node::OptionalSubscriptAccess { object, index } => {
608            collect_called_functions_node(object, calls);
609            collect_called_functions_node(index, calls);
610        }
611        Node::SliceAccess { object, start, end } => {
612            collect_called_functions_node(object, calls);
613            if let Some(start) = start {
614                collect_called_functions_node(start, calls);
615            }
616            if let Some(end) = end {
617                collect_called_functions_node(end, calls);
618            }
619        }
620        Node::BinaryOp { left, right, .. } => {
621            collect_called_functions_node(left, calls);
622            collect_called_functions_node(right, calls);
623        }
624        Node::Ternary {
625            condition,
626            true_expr,
627            false_expr,
628        } => {
629            collect_called_functions_node(condition, calls);
630            collect_called_functions_node(true_expr, calls);
631            collect_called_functions_node(false_expr, calls);
632        }
633        Node::Assignment { target, value, .. } => {
634            collect_called_functions_node(target, calls);
635            collect_called_functions_node(value, calls);
636        }
637        Node::EnumConstruct { args, .. } => collect_many(args, calls),
638        Node::StructConstruct { fields, .. } | Node::DictLiteral(fields) => {
639            collect_dict_calls(fields, calls);
640        }
641        Node::ListLiteral(items) | Node::OrPattern(items) => collect_many(items, calls),
642        Node::HitlExpr { args, .. } => {
643            for arg in args {
644                collect_called_functions_node(&arg.value, calls);
645            }
646        }
647        Node::AttributedDecl { inner, .. } => collect_called_functions_node(inner, calls),
648        Node::Pipeline { body, .. }
649        | Node::OverrideDecl { body, .. }
650        | Node::FnDecl { body, .. }
651        | Node::ToolDecl { body, .. } => collect_many(body, calls),
652        Node::SkillDecl { fields, .. } | Node::EvalPackDecl { fields, .. } => {
653            for (_, value) in fields {
654                collect_called_functions_node(value, calls);
655            }
656        }
657        _ => {}
658    }
659}
660
661fn collect_many(nodes: &[SNode], calls: &mut Vec<String>) {
662    for node in nodes {
663        collect_called_functions_node(node, calls);
664    }
665}
666
667fn collect_dict_calls(entries: &[DictEntry], calls: &mut Vec<String>) {
668    for entry in entries {
669        collect_called_functions_node(&entry.key, calls);
670        collect_called_functions_node(&entry.value, calls);
671    }
672}
673
674pub fn validate_persona_manifests(
675    manifest_path: &Path,
676    personas: &[PersonaManifestEntry],
677    context: &PersonaValidationContext,
678) -> Result<(), Vec<PersonaValidationError>> {
679    let mut errors = Vec::new();
680    for (index, persona) in personas.iter().enumerate() {
681        validate_persona(persona, index, manifest_path, context, &mut errors);
682    }
683    if errors.is_empty() {
684        Ok(())
685    } else {
686        Err(errors)
687    }
688}
689
690pub fn validate_persona(
691    persona: &PersonaManifestEntry,
692    index: usize,
693    manifest_path: &Path,
694    context: &PersonaValidationContext,
695    errors: &mut Vec<PersonaValidationError>,
696) {
697    let root = format!("[[personas]][{index}]");
698    for field in persona.extra.keys() {
699        persona_error(
700            manifest_path,
701            format!("{root}.{field}"),
702            "unknown persona field",
703            errors,
704        );
705    }
706    let name = validate_required_string(
707        manifest_path,
708        &root,
709        "name",
710        persona.name.as_deref(),
711        errors,
712    );
713    if let Some(name) = name {
714        validate_tokenish(manifest_path, &root, "name", name, errors);
715    }
716    validate_required_string(
717        manifest_path,
718        &root,
719        "description",
720        persona.description.as_deref(),
721        errors,
722    );
723    validate_required_string(
724        manifest_path,
725        &root,
726        "entry_workflow",
727        persona.entry_workflow.as_deref(),
728        errors,
729    );
730    if persona.tools.is_empty() && persona.capabilities.is_empty() {
731        persona_error(
732            manifest_path,
733            format!("{root}.tools"),
734            "persona requires at least one tool or capability",
735            errors,
736        );
737    }
738    if persona.autonomy_tier.is_none() {
739        persona_error(
740            manifest_path,
741            format!("{root}.autonomy_tier"),
742            "missing required autonomy tier",
743            errors,
744        );
745    }
746    if persona.receipt_policy.is_none() {
747        persona_error(
748            manifest_path,
749            format!("{root}.receipt_policy"),
750            "missing required receipt policy",
751            errors,
752        );
753    }
754    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
755    for tool in &persona.tools {
756        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
757            persona_error(
758                manifest_path,
759                format!("{root}.tools"),
760                format!("unknown tool '{tool}'"),
761                errors,
762            );
763        }
764    }
765    for capability in &persona.capabilities {
766        let Some((cap, op)) = capability.split_once('.') else {
767            persona_error(
768                manifest_path,
769                format!("{root}.capabilities"),
770                format!("capability '{capability}' must use capability.operation syntax"),
771                errors,
772            );
773            continue;
774        };
775        if cap.trim().is_empty() || op.trim().is_empty() {
776            persona_error(
777                manifest_path,
778                format!("{root}.capabilities"),
779                format!("capability '{capability}' must use capability.operation syntax"),
780                errors,
781            );
782        } else if !context.known_capabilities.is_empty()
783            && !context.known_capabilities.contains(capability)
784        {
785            persona_error(
786                manifest_path,
787                format!("{root}.capabilities"),
788                format!("unknown capability '{capability}'"),
789                errors,
790            );
791        }
792    }
793    validate_string_list(
794        manifest_path,
795        &root,
796        "context_packs",
797        &persona.context_packs,
798        errors,
799    );
800    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
801    for schedule in &persona.schedules {
802        if schedule.trim().is_empty() {
803            persona_error(
804                manifest_path,
805                format!("{root}.schedules"),
806                "schedule entries must not be empty",
807                errors,
808            );
809        } else if let Err(error) = croner::Cron::from_str(schedule) {
810            persona_error(
811                manifest_path,
812                format!("{root}.schedules"),
813                format!("invalid cron schedule '{schedule}': {error}"),
814                errors,
815            );
816        }
817    }
818    for trigger in &persona.triggers {
819        match trigger.split_once('.') {
820            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
821            _ => persona_error(
822                manifest_path,
823                format!("{root}.triggers"),
824                format!("trigger '{trigger}' must use provider.event syntax"),
825                errors,
826            ),
827        }
828    }
829    for handoff in &persona.handoffs {
830        if !context.known_names.contains(handoff) {
831            persona_error(
832                manifest_path,
833                format!("{root}.handoffs"),
834                format!("unknown handoff target '{handoff}'"),
835                errors,
836            );
837        }
838    }
839    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
840    validate_persona_nested_extra(
841        manifest_path,
842        &root,
843        "model_policy",
844        &persona.model_policy.extra,
845        errors,
846    );
847    validate_persona_nested_extra(
848        manifest_path,
849        &root,
850        "package_source",
851        &persona.package_source.extra,
852        errors,
853    );
854    validate_persona_nested_extra(
855        manifest_path,
856        &root,
857        "rollout_policy",
858        &persona.rollout_policy.extra,
859        errors,
860    );
861    if let Some(percentage) = persona.rollout_policy.percentage {
862        if percentage > 100 {
863            persona_error(
864                manifest_path,
865                format!("{root}.rollout_policy.percentage"),
866                "rollout percentage must be between 0 and 100",
867                errors,
868            );
869        }
870    }
871}
872
873pub fn validate_required_string<'a>(
874    manifest_path: &Path,
875    root: &str,
876    field: &str,
877    value: Option<&'a str>,
878    errors: &mut Vec<PersonaValidationError>,
879) -> Option<&'a str> {
880    match value.map(str::trim) {
881        Some(value) if !value.is_empty() => Some(value),
882        _ => {
883            persona_error(
884                manifest_path,
885                format!("{root}.{field}"),
886                format!("missing required {field}"),
887                errors,
888            );
889            None
890        }
891    }
892}
893
894pub fn validate_string_list(
895    manifest_path: &Path,
896    root: &str,
897    field: &str,
898    values: &[String],
899    errors: &mut Vec<PersonaValidationError>,
900) {
901    for value in values {
902        if value.trim().is_empty() {
903            persona_error(
904                manifest_path,
905                format!("{root}.{field}"),
906                format!("{field} entries must not be empty"),
907                errors,
908            );
909        } else {
910            validate_tokenish(manifest_path, root, field, value, errors);
911        }
912    }
913}
914
915pub fn validate_tokenish(
916    manifest_path: &Path,
917    root: &str,
918    field: &str,
919    value: &str,
920    errors: &mut Vec<PersonaValidationError>,
921) {
922    if !value
923        .chars()
924        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
925    {
926        persona_error(
927            manifest_path,
928            format!("{root}.{field}"),
929            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
930            errors,
931        );
932    }
933}
934
935pub fn validate_persona_budget(
936    manifest_path: &Path,
937    root: &str,
938    budget: &PersonaBudget,
939    errors: &mut Vec<PersonaValidationError>,
940) {
941    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
942    for (field, value) in [
943        ("daily_usd", budget.daily_usd),
944        ("hourly_usd", budget.hourly_usd),
945        ("run_usd", budget.run_usd),
946    ] {
947        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
948            persona_error(
949                manifest_path,
950                format!("{root}.budget.{field}"),
951                "budget amounts must be finite non-negative numbers",
952                errors,
953            );
954        }
955    }
956}
957
958pub fn validate_persona_nested_extra(
959    manifest_path: &Path,
960    root: &str,
961    field: &str,
962    extra: &BTreeMap<String, toml::Value>,
963    errors: &mut Vec<PersonaValidationError>,
964) {
965    for key in extra.keys() {
966        persona_error(
967            manifest_path,
968            format!("{root}.{field}.{key}"),
969            format!("unknown {field} field"),
970            errors,
971        );
972    }
973}
974
975pub fn persona_error(
976    manifest_path: &Path,
977    field_path: String,
978    message: impl Into<String>,
979    errors: &mut Vec<PersonaValidationError>,
980) {
981    errors.push(PersonaValidationError {
982        manifest_path: manifest_path.to_path_buf(),
983        field_path,
984        message: message.into(),
985    });
986}
987
988pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
989    BTreeMap::from([
990        (
991            "workspace",
992            vec![
993                "read_text",
994                "write_text",
995                "apply_edit",
996                "delete",
997                "exists",
998                "file_exists",
999                "list",
1000                "project_root",
1001                "roots",
1002            ],
1003        ),
1004        ("process", vec!["exec"]),
1005        ("template", vec!["render"]),
1006        ("interaction", vec!["ask"]),
1007        (
1008            "runtime",
1009            vec![
1010                "approved_plan",
1011                "dry_run",
1012                "pipeline_input",
1013                "record_run",
1014                "set_result",
1015                "task",
1016            ],
1017        ),
1018        (
1019            "project",
1020            vec![
1021                "agent_instructions",
1022                "code_patterns",
1023                "compute_content_hash",
1024                "ide_context",
1025                "lessons",
1026                "mcp_config",
1027                "metadata_get",
1028                "metadata_refresh_hashes",
1029                "metadata_save",
1030                "metadata_set",
1031                "metadata_stale",
1032                "scan",
1033                "scope_test_command",
1034                "test_commands",
1035            ],
1036        ),
1037        (
1038            "session",
1039            vec![
1040                "active_roots",
1041                "changed_paths",
1042                "preread_get",
1043                "preread_read_many",
1044            ],
1045        ),
1046        (
1047            "editor",
1048            vec!["get_active_file", "get_selection", "get_visible_files"],
1049        ),
1050        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
1051        ("git", vec!["get_branch", "get_diff"]),
1052        ("learning", vec!["get_learned_rules", "report_correction"]),
1053    ])
1054}
1055
1056pub fn default_persona_capabilities() -> BTreeSet<String> {
1057    let mut capabilities = BTreeSet::new();
1058    for (capability, operations) in default_persona_capability_map() {
1059        for operation in operations {
1060            capabilities.insert(format!("{capability}.{operation}"));
1061        }
1062    }
1063    capabilities
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069
1070    fn context(names: &[&str]) -> PersonaValidationContext {
1071        PersonaValidationContext {
1072            known_capabilities: default_persona_capabilities(),
1073            known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
1074            known_names: names.iter().map(|name| name.to_string()).collect(),
1075        }
1076    }
1077
1078    #[test]
1079    fn validates_sample_manifest() {
1080        let parsed = parse_persona_manifest_str(
1081            r#"
1082[[personas]]
1083name = "merge_captain"
1084description = "Owns PR readiness."
1085entry_workflow = "workflows/merge_captain.harn#run"
1086tools = ["github", "ci"]
1087capabilities = ["git.get_diff"]
1088autonomy = "act_with_approval"
1089receipts = "required"
1090triggers = ["github.pr_opened"]
1091schedules = ["*/30 * * * *"]
1092handoffs = ["review_captain"]
1093context_packs = ["repo_policy"]
1094evals = ["merge_safety"]
1095budget = { daily_usd = 20.0 }
1096
1097[[personas]]
1098name = "review_captain"
1099description = "Reviews code."
1100entry_workflow = "workflows/review_captain.harn#run"
1101tools = ["github"]
1102autonomy_tier = "suggest"
1103receipt_policy = "optional"
1104"#,
1105        )
1106        .expect("manifest parses");
1107
1108        validate_persona_manifests(
1109            Path::new("harn.toml"),
1110            &parsed.personas,
1111            &context(&["merge_captain", "review_captain"]),
1112        )
1113        .expect("manifest validates");
1114    }
1115
1116    #[test]
1117    fn bad_manifest_produces_typed_errors() {
1118        let parsed = parse_persona_manifest_str(
1119            r#"
1120[[personas]]
1121name = "bad"
1122description = ""
1123entry_workflow = ""
1124tools = ["unknown"]
1125capabilities = ["git"]
1126autonomy = "shadow"
1127receipts = "required"
1128triggers = ["github"]
1129schedules = [""]
1130handoffs = ["missing"]
1131budget = { daily_usd = -1.0, surprise = true }
1132surprise = true
1133"#,
1134        )
1135        .expect("manifest parses");
1136
1137        let errors = validate_persona_manifests(
1138            Path::new("harn.toml"),
1139            &parsed.personas,
1140            &context(&["bad"]),
1141        )
1142        .expect_err("manifest rejects");
1143        let fields: BTreeSet<_> = errors
1144            .iter()
1145            .map(|error| error.field_path.as_str())
1146            .collect();
1147        assert!(fields.contains("[[personas]][0].description"));
1148        assert!(fields.contains("[[personas]][0].entry_workflow"));
1149        assert!(fields.contains("[[personas]][0].tools"));
1150        assert!(fields.contains("[[personas]][0].capabilities"));
1151        assert!(fields.contains("[[personas]][0].triggers"));
1152        assert!(fields.contains("[[personas]][0].schedules"));
1153        assert!(fields.contains("[[personas]][0].handoffs"));
1154        assert!(fields.contains("[[personas]][0].budget.daily_usd"));
1155        assert!(fields.contains("[[personas]][0].budget.surprise"));
1156        assert!(fields.contains("[[personas]][0].surprise"));
1157    }
1158
1159    #[test]
1160    fn source_persona_extracts_called_steps_in_order() {
1161        let parsed = parse_persona_source_str(
1162            r#"
1163@persona(name: "merge_captain")
1164fn merge_captain(ctx) {
1165  plan(ctx)
1166  verify(ctx)
1167}
1168
1169@step(name: "plan", model: "gpt-5.4-mini", retry: {max_attempts: 2})
1170fn plan(ctx) {
1171  return ctx
1172}
1173
1174@step(name: "verify", error_boundary: continue)
1175fn verify(ctx) {
1176  return ctx
1177}
1178"#,
1179        )
1180        .expect("source persona parses");
1181        assert_eq!(parsed.personas.len(), 1);
1182        let persona = &parsed.personas[0];
1183        assert_eq!(persona.name.as_deref(), Some("merge_captain"));
1184        assert_eq!(persona.steps.len(), 2);
1185        assert_eq!(persona.steps[0].name, "plan");
1186        assert_eq!(persona.steps[0].model.as_deref(), Some("gpt-5.4-mini"));
1187        assert_eq!(persona.steps[0].retry.as_ref().unwrap().max_attempts, 2);
1188        assert_eq!(persona.steps[1].error_boundary.as_deref(), Some("continue"));
1189    }
1190}