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            body,
536            catch_body,
537            finally_body,
538            ..
539        } => {
540            collect_many(body, calls);
541            collect_many(catch_body, calls);
542            if let Some(finally_body) = finally_body {
543                collect_many(finally_body, calls);
544            }
545        }
546        Node::TryExpr { body }
547        | Node::SpawnExpr { body }
548        | Node::DeferStmt { body }
549        | Node::MutexBlock { body }
550        | Node::Block(body)
551        | Node::Closure { body, .. } => collect_many(body, calls),
552        Node::DeadlineBlock { duration, body } => {
553            collect_called_functions_node(duration, calls);
554            collect_many(body, calls);
555        }
556        Node::GuardStmt {
557            condition,
558            else_body,
559        } => {
560            collect_called_functions_node(condition, calls);
561            collect_many(else_body, calls);
562        }
563        Node::RequireStmt { condition, message } => {
564            collect_called_functions_node(condition, calls);
565            if let Some(message) = message {
566                collect_called_functions_node(message, calls);
567            }
568        }
569        Node::Parallel {
570            expr,
571            body,
572            options,
573            ..
574        } => {
575            collect_called_functions_node(expr, calls);
576            for (_, value) in options {
577                collect_called_functions_node(value, calls);
578            }
579            collect_many(body, calls);
580        }
581        Node::SelectExpr {
582            cases,
583            timeout,
584            default_body,
585        } => {
586            for case in cases {
587                collect_called_functions_node(&case.channel, calls);
588                collect_many(&case.body, calls);
589            }
590            if let Some((duration, body)) = timeout {
591                collect_called_functions_node(duration, calls);
592                collect_many(body, calls);
593            }
594            if let Some(body) = default_body {
595                collect_many(body, calls);
596            }
597        }
598        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
599            collect_called_functions_node(object, calls);
600            collect_many(args, calls);
601        }
602        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
603            collect_called_functions_node(object, calls);
604        }
605        Node::SubscriptAccess { object, index }
606        | Node::OptionalSubscriptAccess { object, index } => {
607            collect_called_functions_node(object, calls);
608            collect_called_functions_node(index, calls);
609        }
610        Node::SliceAccess { object, start, end } => {
611            collect_called_functions_node(object, calls);
612            if let Some(start) = start {
613                collect_called_functions_node(start, calls);
614            }
615            if let Some(end) = end {
616                collect_called_functions_node(end, calls);
617            }
618        }
619        Node::BinaryOp { left, right, .. } => {
620            collect_called_functions_node(left, calls);
621            collect_called_functions_node(right, calls);
622        }
623        Node::Ternary {
624            condition,
625            true_expr,
626            false_expr,
627        } => {
628            collect_called_functions_node(condition, calls);
629            collect_called_functions_node(true_expr, calls);
630            collect_called_functions_node(false_expr, calls);
631        }
632        Node::Assignment { target, value, .. } => {
633            collect_called_functions_node(target, calls);
634            collect_called_functions_node(value, calls);
635        }
636        Node::EnumConstruct { args, .. } => collect_many(args, calls),
637        Node::StructConstruct { fields, .. } | Node::DictLiteral(fields) => {
638            collect_dict_calls(fields, calls);
639        }
640        Node::ListLiteral(items) | Node::OrPattern(items) => collect_many(items, calls),
641        Node::HitlExpr { args, .. } => {
642            for arg in args {
643                collect_called_functions_node(&arg.value, calls);
644            }
645        }
646        Node::AttributedDecl { inner, .. } => collect_called_functions_node(inner, calls),
647        Node::Pipeline { body, .. }
648        | Node::OverrideDecl { body, .. }
649        | Node::FnDecl { body, .. }
650        | Node::ToolDecl { body, .. } => collect_many(body, calls),
651        Node::SkillDecl { fields, .. } | Node::EvalPackDecl { fields, .. } => {
652            for (_, value) in fields {
653                collect_called_functions_node(value, calls);
654            }
655        }
656        _ => {}
657    }
658}
659
660fn collect_many(nodes: &[SNode], calls: &mut Vec<String>) {
661    for node in nodes {
662        collect_called_functions_node(node, calls);
663    }
664}
665
666fn collect_dict_calls(entries: &[DictEntry], calls: &mut Vec<String>) {
667    for entry in entries {
668        collect_called_functions_node(&entry.key, calls);
669        collect_called_functions_node(&entry.value, calls);
670    }
671}
672
673pub fn validate_persona_manifests(
674    manifest_path: &Path,
675    personas: &[PersonaManifestEntry],
676    context: &PersonaValidationContext,
677) -> Result<(), Vec<PersonaValidationError>> {
678    let mut errors = Vec::new();
679    for (index, persona) in personas.iter().enumerate() {
680        validate_persona(persona, index, manifest_path, context, &mut errors);
681    }
682    if errors.is_empty() {
683        Ok(())
684    } else {
685        Err(errors)
686    }
687}
688
689pub fn validate_persona(
690    persona: &PersonaManifestEntry,
691    index: usize,
692    manifest_path: &Path,
693    context: &PersonaValidationContext,
694    errors: &mut Vec<PersonaValidationError>,
695) {
696    let root = format!("[[personas]][{index}]");
697    for field in persona.extra.keys() {
698        persona_error(
699            manifest_path,
700            format!("{root}.{field}"),
701            "unknown persona field",
702            errors,
703        );
704    }
705    let name = validate_required_string(
706        manifest_path,
707        &root,
708        "name",
709        persona.name.as_deref(),
710        errors,
711    );
712    if let Some(name) = name {
713        validate_tokenish(manifest_path, &root, "name", name, errors);
714    }
715    validate_required_string(
716        manifest_path,
717        &root,
718        "description",
719        persona.description.as_deref(),
720        errors,
721    );
722    validate_required_string(
723        manifest_path,
724        &root,
725        "entry_workflow",
726        persona.entry_workflow.as_deref(),
727        errors,
728    );
729    if persona.tools.is_empty() && persona.capabilities.is_empty() {
730        persona_error(
731            manifest_path,
732            format!("{root}.tools"),
733            "persona requires at least one tool or capability",
734            errors,
735        );
736    }
737    if persona.autonomy_tier.is_none() {
738        persona_error(
739            manifest_path,
740            format!("{root}.autonomy_tier"),
741            "missing required autonomy tier",
742            errors,
743        );
744    }
745    if persona.receipt_policy.is_none() {
746        persona_error(
747            manifest_path,
748            format!("{root}.receipt_policy"),
749            "missing required receipt policy",
750            errors,
751        );
752    }
753    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
754    for tool in &persona.tools {
755        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
756            persona_error(
757                manifest_path,
758                format!("{root}.tools"),
759                format!("unknown tool '{tool}'"),
760                errors,
761            );
762        }
763    }
764    for capability in &persona.capabilities {
765        let Some((cap, op)) = capability.split_once('.') else {
766            persona_error(
767                manifest_path,
768                format!("{root}.capabilities"),
769                format!("capability '{capability}' must use capability.operation syntax"),
770                errors,
771            );
772            continue;
773        };
774        if cap.trim().is_empty() || op.trim().is_empty() {
775            persona_error(
776                manifest_path,
777                format!("{root}.capabilities"),
778                format!("capability '{capability}' must use capability.operation syntax"),
779                errors,
780            );
781        } else if !context.known_capabilities.is_empty()
782            && !context.known_capabilities.contains(capability)
783        {
784            persona_error(
785                manifest_path,
786                format!("{root}.capabilities"),
787                format!("unknown capability '{capability}'"),
788                errors,
789            );
790        }
791    }
792    validate_string_list(
793        manifest_path,
794        &root,
795        "context_packs",
796        &persona.context_packs,
797        errors,
798    );
799    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
800    for schedule in &persona.schedules {
801        if schedule.trim().is_empty() {
802            persona_error(
803                manifest_path,
804                format!("{root}.schedules"),
805                "schedule entries must not be empty",
806                errors,
807            );
808        } else if let Err(error) = croner::Cron::from_str(schedule) {
809            persona_error(
810                manifest_path,
811                format!("{root}.schedules"),
812                format!("invalid cron schedule '{schedule}': {error}"),
813                errors,
814            );
815        }
816    }
817    for trigger in &persona.triggers {
818        match trigger.split_once('.') {
819            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
820            _ => persona_error(
821                manifest_path,
822                format!("{root}.triggers"),
823                format!("trigger '{trigger}' must use provider.event syntax"),
824                errors,
825            ),
826        }
827    }
828    for handoff in &persona.handoffs {
829        if !context.known_names.contains(handoff) {
830            persona_error(
831                manifest_path,
832                format!("{root}.handoffs"),
833                format!("unknown handoff target '{handoff}'"),
834                errors,
835            );
836        }
837    }
838    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
839    validate_persona_nested_extra(
840        manifest_path,
841        &root,
842        "model_policy",
843        &persona.model_policy.extra,
844        errors,
845    );
846    validate_persona_nested_extra(
847        manifest_path,
848        &root,
849        "package_source",
850        &persona.package_source.extra,
851        errors,
852    );
853    validate_persona_nested_extra(
854        manifest_path,
855        &root,
856        "rollout_policy",
857        &persona.rollout_policy.extra,
858        errors,
859    );
860    if let Some(percentage) = persona.rollout_policy.percentage {
861        if percentage > 100 {
862            persona_error(
863                manifest_path,
864                format!("{root}.rollout_policy.percentage"),
865                "rollout percentage must be between 0 and 100",
866                errors,
867            );
868        }
869    }
870}
871
872pub fn validate_required_string<'a>(
873    manifest_path: &Path,
874    root: &str,
875    field: &str,
876    value: Option<&'a str>,
877    errors: &mut Vec<PersonaValidationError>,
878) -> Option<&'a str> {
879    match value.map(str::trim) {
880        Some(value) if !value.is_empty() => Some(value),
881        _ => {
882            persona_error(
883                manifest_path,
884                format!("{root}.{field}"),
885                format!("missing required {field}"),
886                errors,
887            );
888            None
889        }
890    }
891}
892
893pub fn validate_string_list(
894    manifest_path: &Path,
895    root: &str,
896    field: &str,
897    values: &[String],
898    errors: &mut Vec<PersonaValidationError>,
899) {
900    for value in values {
901        if value.trim().is_empty() {
902            persona_error(
903                manifest_path,
904                format!("{root}.{field}"),
905                format!("{field} entries must not be empty"),
906                errors,
907            );
908        } else {
909            validate_tokenish(manifest_path, root, field, value, errors);
910        }
911    }
912}
913
914pub fn validate_tokenish(
915    manifest_path: &Path,
916    root: &str,
917    field: &str,
918    value: &str,
919    errors: &mut Vec<PersonaValidationError>,
920) {
921    if !value
922        .chars()
923        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
924    {
925        persona_error(
926            manifest_path,
927            format!("{root}.{field}"),
928            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
929            errors,
930        );
931    }
932}
933
934pub fn validate_persona_budget(
935    manifest_path: &Path,
936    root: &str,
937    budget: &PersonaBudget,
938    errors: &mut Vec<PersonaValidationError>,
939) {
940    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
941    for (field, value) in [
942        ("daily_usd", budget.daily_usd),
943        ("hourly_usd", budget.hourly_usd),
944        ("run_usd", budget.run_usd),
945    ] {
946        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
947            persona_error(
948                manifest_path,
949                format!("{root}.budget.{field}"),
950                "budget amounts must be finite non-negative numbers",
951                errors,
952            );
953        }
954    }
955}
956
957pub fn validate_persona_nested_extra(
958    manifest_path: &Path,
959    root: &str,
960    field: &str,
961    extra: &BTreeMap<String, toml::Value>,
962    errors: &mut Vec<PersonaValidationError>,
963) {
964    for key in extra.keys() {
965        persona_error(
966            manifest_path,
967            format!("{root}.{field}.{key}"),
968            format!("unknown {field} field"),
969            errors,
970        );
971    }
972}
973
974pub fn persona_error(
975    manifest_path: &Path,
976    field_path: String,
977    message: impl Into<String>,
978    errors: &mut Vec<PersonaValidationError>,
979) {
980    errors.push(PersonaValidationError {
981        manifest_path: manifest_path.to_path_buf(),
982        field_path,
983        message: message.into(),
984    });
985}
986
987pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
988    BTreeMap::from([
989        (
990            "workspace",
991            vec![
992                "read_text",
993                "write_text",
994                "apply_edit",
995                "delete",
996                "exists",
997                "file_exists",
998                "list",
999                "project_root",
1000                "roots",
1001            ],
1002        ),
1003        ("process", vec!["exec"]),
1004        ("template", vec!["render"]),
1005        ("interaction", vec!["ask"]),
1006        (
1007            "runtime",
1008            vec![
1009                "approved_plan",
1010                "dry_run",
1011                "pipeline_input",
1012                "record_run",
1013                "set_result",
1014                "task",
1015            ],
1016        ),
1017        (
1018            "project",
1019            vec![
1020                "agent_instructions",
1021                "code_patterns",
1022                "compute_content_hash",
1023                "ide_context",
1024                "lessons",
1025                "mcp_config",
1026                "metadata_get",
1027                "metadata_refresh_hashes",
1028                "metadata_save",
1029                "metadata_set",
1030                "metadata_stale",
1031                "scan",
1032                "scope_test_command",
1033                "test_commands",
1034            ],
1035        ),
1036        (
1037            "session",
1038            vec![
1039                "active_roots",
1040                "changed_paths",
1041                "preread_get",
1042                "preread_read_many",
1043            ],
1044        ),
1045        (
1046            "editor",
1047            vec!["get_active_file", "get_selection", "get_visible_files"],
1048        ),
1049        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
1050        ("git", vec!["get_branch", "get_diff"]),
1051        ("learning", vec!["get_learned_rules", "report_correction"]),
1052    ])
1053}
1054
1055pub fn default_persona_capabilities() -> BTreeSet<String> {
1056    let mut capabilities = BTreeSet::new();
1057    for (capability, operations) in default_persona_capability_map() {
1058        for operation in operations {
1059            capabilities.insert(format!("{capability}.{operation}"));
1060        }
1061    }
1062    capabilities
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    fn context(names: &[&str]) -> PersonaValidationContext {
1070        PersonaValidationContext {
1071            known_capabilities: default_persona_capabilities(),
1072            known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
1073            known_names: names.iter().map(|name| name.to_string()).collect(),
1074        }
1075    }
1076
1077    #[test]
1078    fn validates_sample_manifest() {
1079        let parsed = parse_persona_manifest_str(
1080            r#"
1081[[personas]]
1082name = "merge_captain"
1083description = "Owns PR readiness."
1084entry_workflow = "workflows/merge_captain.harn#run"
1085tools = ["github", "ci"]
1086capabilities = ["git.get_diff"]
1087autonomy = "act_with_approval"
1088receipts = "required"
1089triggers = ["github.pr_opened"]
1090schedules = ["*/30 * * * *"]
1091handoffs = ["review_captain"]
1092context_packs = ["repo_policy"]
1093evals = ["merge_safety"]
1094budget = { daily_usd = 20.0 }
1095
1096[[personas]]
1097name = "review_captain"
1098description = "Reviews code."
1099entry_workflow = "workflows/review_captain.harn#run"
1100tools = ["github"]
1101autonomy_tier = "suggest"
1102receipt_policy = "optional"
1103"#,
1104        )
1105        .expect("manifest parses");
1106
1107        validate_persona_manifests(
1108            Path::new("harn.toml"),
1109            &parsed.personas,
1110            &context(&["merge_captain", "review_captain"]),
1111        )
1112        .expect("manifest validates");
1113    }
1114
1115    #[test]
1116    fn bad_manifest_produces_typed_errors() {
1117        let parsed = parse_persona_manifest_str(
1118            r#"
1119[[personas]]
1120name = "bad"
1121description = ""
1122entry_workflow = ""
1123tools = ["unknown"]
1124capabilities = ["git"]
1125autonomy = "shadow"
1126receipts = "required"
1127triggers = ["github"]
1128schedules = [""]
1129handoffs = ["missing"]
1130budget = { daily_usd = -1.0, surprise = true }
1131surprise = true
1132"#,
1133        )
1134        .expect("manifest parses");
1135
1136        let errors = validate_persona_manifests(
1137            Path::new("harn.toml"),
1138            &parsed.personas,
1139            &context(&["bad"]),
1140        )
1141        .expect_err("manifest rejects");
1142        let fields: BTreeSet<_> = errors
1143            .iter()
1144            .map(|error| error.field_path.as_str())
1145            .collect();
1146        assert!(fields.contains("[[personas]][0].description"));
1147        assert!(fields.contains("[[personas]][0].entry_workflow"));
1148        assert!(fields.contains("[[personas]][0].tools"));
1149        assert!(fields.contains("[[personas]][0].capabilities"));
1150        assert!(fields.contains("[[personas]][0].triggers"));
1151        assert!(fields.contains("[[personas]][0].schedules"));
1152        assert!(fields.contains("[[personas]][0].handoffs"));
1153        assert!(fields.contains("[[personas]][0].budget.daily_usd"));
1154        assert!(fields.contains("[[personas]][0].budget.surprise"));
1155        assert!(fields.contains("[[personas]][0].surprise"));
1156    }
1157
1158    #[test]
1159    fn source_persona_extracts_called_steps_in_order() {
1160        let parsed = parse_persona_source_str(
1161            r#"
1162@persona(name: "merge_captain")
1163fn merge_captain(ctx) {
1164  plan(ctx)
1165  verify(ctx)
1166}
1167
1168@step(name: "plan", model: "gpt-5.4-mini", retry: {max_attempts: 2})
1169fn plan(ctx) {
1170  return ctx
1171}
1172
1173@step(name: "verify", error_boundary: continue)
1174fn verify(ctx) {
1175  return ctx
1176}
1177"#,
1178        )
1179        .expect("source persona parses");
1180        assert_eq!(parsed.personas.len(), 1);
1181        let persona = &parsed.personas[0];
1182        assert_eq!(persona.name.as_deref(), Some("merge_captain"));
1183        assert_eq!(persona.steps.len(), 2);
1184        assert_eq!(persona.steps[0].name, "plan");
1185        assert_eq!(persona.steps[0].model.as_deref(), Some("gpt-5.4-mini"));
1186        assert_eq!(persona.steps[0].retry.as_ref().unwrap().max_attempts, 2);
1187        assert_eq!(persona.steps[1].error_boundary.as_deref(), Some("continue"));
1188    }
1189}