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