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    /// Per-stage tool-surface narrowing. Each stage names a `@step` and
56    /// declares the tools / side-effect ceiling enforced while that step
57    /// runs.
58    #[serde(default)]
59    pub stages: Vec<PersonaStageDecl>,
60    #[serde(flatten, default)]
61    pub extra: BTreeMap<String, toml::Value>,
62}
63
64/// Stage declaration carried on a `PersonaManifestEntry`.
65///
66/// Mirrors the runtime `harn_vm::StageDecl` shape so loaders can map
67/// directly. `allowed_tools = None` means "inherit the persona-level
68/// tool list"; `Some(vec![])` means "deny every tool while this stage is
69/// active".
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71pub struct PersonaStageDecl {
72    pub name: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub allowed_tools: Option<Vec<String>>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub side_effect_level: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub max_iterations: Option<u32>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub on_exit: Option<PersonaStageExit>,
81    #[serde(flatten, default)]
82    pub extra: BTreeMap<String, toml::Value>,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
86pub struct PersonaStageExit {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub on_complete: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub on_failure: Option<String>,
91    #[serde(flatten, default)]
92    pub extra: BTreeMap<String, toml::Value>,
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96pub struct PersonaStepMetadata {
97    pub name: String,
98    pub function: String,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub model: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub approval: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub receipt: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub error_boundary: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub retry: Option<PersonaStepRetry>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub budget: Option<PersonaStepBudget>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub line: Option<usize>,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
116pub struct PersonaStepRetry {
117    pub max_attempts: u64,
118}
119
120/// Per-step token / cost ceiling. Either field is optional; whichever is
121/// set governs that dimension. Surfaced statically by `harn persona
122/// inspect --json` and consumed at runtime by `crates/harn-vm/src/step_runtime.rs`
123/// to short-circuit `llm_call` invocations before they exceed the limit.
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
125pub struct PersonaStepBudget {
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub max_tokens: Option<u64>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub max_usd: Option<f64>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum PersonaAutonomyTier {
135    Shadow,
136    Suggest,
137    ActWithApproval,
138    ActAuto,
139}
140
141impl PersonaAutonomyTier {
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Self::Shadow => "shadow",
145            Self::Suggest => "suggest",
146            Self::ActWithApproval => "act_with_approval",
147            Self::ActAuto => "act_auto",
148        }
149    }
150}
151
152impl FromStr for PersonaAutonomyTier {
153    type Err = ();
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        match value {
157            "shadow" => Ok(Self::Shadow),
158            "suggest" => Ok(Self::Suggest),
159            "act_with_approval" => Ok(Self::ActWithApproval),
160            "act_auto" => Ok(Self::ActAuto),
161            _ => Err(()),
162        }
163    }
164}
165
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum PersonaReceiptPolicy {
169    #[default]
170    Optional,
171    Required,
172    Disabled,
173}
174
175impl PersonaReceiptPolicy {
176    pub fn as_str(self) -> &'static str {
177        match self {
178            Self::Optional => "optional",
179            Self::Required => "required",
180            Self::Disabled => "disabled",
181        }
182    }
183}
184
185impl FromStr for PersonaReceiptPolicy {
186    type Err = ();
187
188    fn from_str(value: &str) -> Result<Self, Self::Err> {
189        match value {
190            "optional" => Ok(Self::Optional),
191            "required" => Ok(Self::Required),
192            "disabled" => Ok(Self::Disabled),
193            "none" => Ok(Self::Disabled),
194            _ => Err(()),
195        }
196    }
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
200pub struct PersonaModelPolicy {
201    #[serde(default)]
202    pub default_model: Option<String>,
203    #[serde(default)]
204    pub escalation_model: Option<String>,
205    #[serde(default)]
206    pub fallback_models: Vec<String>,
207    #[serde(default)]
208    pub reasoning_effort: Option<String>,
209    #[serde(flatten, default)]
210    pub extra: BTreeMap<String, toml::Value>,
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
214pub struct PersonaBudget {
215    #[serde(default)]
216    pub daily_usd: Option<f64>,
217    #[serde(default)]
218    pub hourly_usd: Option<f64>,
219    #[serde(default)]
220    pub run_usd: Option<f64>,
221    #[serde(default)]
222    pub frontier_escalations: Option<u32>,
223    #[serde(default)]
224    pub max_tokens: Option<u64>,
225    #[serde(default)]
226    pub max_runtime_seconds: Option<u64>,
227    #[serde(flatten, default)]
228    pub extra: BTreeMap<String, toml::Value>,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
232pub struct PersonaPackageSource {
233    #[serde(default)]
234    pub package: Option<String>,
235    #[serde(default)]
236    pub path: Option<String>,
237    #[serde(default)]
238    pub git: Option<String>,
239    #[serde(default)]
240    pub rev: Option<String>,
241    #[serde(flatten, default)]
242    pub extra: BTreeMap<String, toml::Value>,
243}
244
245#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
246pub struct PersonaRolloutPolicy {
247    #[serde(default)]
248    pub mode: Option<String>,
249    #[serde(default)]
250    pub percentage: Option<u8>,
251    #[serde(default)]
252    pub cohorts: Vec<String>,
253    #[serde(flatten, default)]
254    pub extra: BTreeMap<String, toml::Value>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize)]
258pub struct ResolvedPersonaManifest {
259    pub manifest_path: PathBuf,
260    pub manifest_dir: PathBuf,
261    pub personas: Vec<PersonaManifestEntry>,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize)]
265pub struct PersonaValidationError {
266    pub manifest_path: PathBuf,
267    pub field_path: String,
268    pub message: String,
269}
270
271impl std::fmt::Display for PersonaValidationError {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        write!(
274            f,
275            "{} {}: {}",
276            self.manifest_path.display(),
277            self.field_path,
278            self.message
279        )
280    }
281}
282
283impl std::error::Error for PersonaValidationError {}
284
285#[derive(Debug, Clone, Default)]
286pub struct PersonaValidationContext {
287    pub known_capabilities: BTreeSet<String>,
288    pub known_tools: BTreeSet<String>,
289    pub known_names: BTreeSet<String>,
290}
291
292pub fn parse_persona_manifest_str(
293    source: &str,
294) -> Result<PersonaManifestDocument, toml::de::Error> {
295    let document = toml::from_str::<PersonaManifestDocument>(source)?;
296    if !document.personas.is_empty() {
297        return Ok(document);
298    }
299    let entry = toml::from_str::<PersonaManifestEntry>(source)?;
300    if entry.name.is_some()
301        || entry.description.is_some()
302        || entry.entry_workflow.is_some()
303        || !entry.tools.is_empty()
304        || !entry.capabilities.is_empty()
305    {
306        Ok(PersonaManifestDocument {
307            personas: vec![entry],
308        })
309    } else {
310        Ok(document)
311    }
312}
313
314pub fn parse_persona_manifest_file(path: &Path) -> Result<PersonaManifestDocument, String> {
315    let content = fs::read_to_string(path)
316        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
317    parse_persona_manifest_str(&content)
318        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
319}
320
321pub fn parse_persona_source_file(path: &Path) -> Result<PersonaManifestDocument, String> {
322    let content = fs::read_to_string(path)
323        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
324    parse_persona_source_str(&content)
325        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
326}
327
328pub fn parse_persona_source_str(source: &str) -> Result<PersonaManifestDocument, String> {
329    let program = harn_parser::parse_source(source).map_err(|error| error.to_string())?;
330    Ok(extract_personas_from_program(&program))
331}
332
333pub fn extract_personas_from_program(program: &[SNode]) -> PersonaManifestDocument {
334    let step_decls = collect_step_declarations(program);
335    let mut personas = Vec::new();
336    for snode in program {
337        let Node::AttributedDecl { attributes, inner } = &snode.node else {
338            continue;
339        };
340        let Some(persona_attr) = attributes.iter().find(|attr| attr.name == "persona") else {
341            continue;
342        };
343        let Node::FnDecl { name, body, .. } = &inner.node else {
344            continue;
345        };
346        let persona_name = attr_string(persona_attr, "name").unwrap_or_else(|| name.clone());
347        let mut seen = BTreeSet::new();
348        let mut steps = Vec::new();
349        for call_name in collect_called_functions(body) {
350            if !seen.insert(call_name.clone()) {
351                continue;
352            }
353            if let Some(step) = step_decls.get(&call_name) {
354                steps.push(step.clone());
355            }
356        }
357        personas.push(PersonaManifestEntry {
358            name: Some(persona_name),
359            description: Some(
360                attr_string(persona_attr, "description")
361                    .unwrap_or_else(|| "Source-declared persona".to_string()),
362            ),
363            entry_workflow: Some(name.clone()),
364            tools: attr_string_list(persona_attr, "tools"),
365            capabilities: {
366                let capabilities = attr_string_list(persona_attr, "capabilities");
367                if capabilities.is_empty() {
368                    vec!["project.test_commands".to_string()]
369                } else {
370                    capabilities
371                }
372            },
373            autonomy_tier: attr_string(persona_attr, "autonomy")
374                .as_deref()
375                .and_then(|value| PersonaAutonomyTier::from_str(value).ok())
376                .or(Some(PersonaAutonomyTier::Suggest)),
377            receipt_policy: attr_string(persona_attr, "receipts")
378                .as_deref()
379                .and_then(|value| PersonaReceiptPolicy::from_str(value).ok())
380                .or(Some(PersonaReceiptPolicy::Optional)),
381            steps,
382            stages: attr_stage_list(persona_attr),
383            ..PersonaManifestEntry::default()
384        });
385    }
386    PersonaManifestDocument { personas }
387}
388
389pub fn extract_step_metadata_from_program(program: &[SNode]) -> Vec<PersonaStepMetadata> {
390    collect_step_declarations(program).into_values().collect()
391}
392
393fn collect_step_declarations(program: &[SNode]) -> BTreeMap<String, PersonaStepMetadata> {
394    let mut steps = BTreeMap::new();
395    for snode in program {
396        let Node::AttributedDecl { attributes, inner } = &snode.node else {
397            continue;
398        };
399        let Some(step_attr) = attributes.iter().find(|attr| attr.name == "step") else {
400            continue;
401        };
402        let Node::FnDecl { name, .. } = &inner.node else {
403            continue;
404        };
405        steps.insert(
406            name.clone(),
407            PersonaStepMetadata {
408                name: attr_string(step_attr, "name").unwrap_or_else(|| name.clone()),
409                function: name.clone(),
410                model: attr_string(step_attr, "model"),
411                approval: attr_string(step_attr, "approval"),
412                receipt: attr_string(step_attr, "receipt"),
413                error_boundary: attr_string(step_attr, "error_boundary"),
414                retry: attr_retry(step_attr),
415                budget: attr_step_budget(step_attr),
416                line: Some(inner.span.line),
417            },
418        );
419    }
420    steps
421}
422
423fn attr_string(attr: &Attribute, key: &str) -> Option<String> {
424    attr.named_arg(key).and_then(node_string)
425}
426
427fn attr_string_list(attr: &Attribute, key: &str) -> Vec<String> {
428    let Some(value) = attr.named_arg(key) else {
429        return Vec::new();
430    };
431    let Node::ListLiteral(items) = &value.node else {
432        return Vec::new();
433    };
434    items.iter().filter_map(node_string).collect()
435}
436
437fn node_string(node: &SNode) -> Option<String> {
438    match &node.node {
439        Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
440            Some(value.clone())
441        }
442        _ => None,
443    }
444}
445
446fn attr_stage_list(attr: &Attribute) -> Vec<PersonaStageDecl> {
447    let Some(value) = attr.named_arg("stages") else {
448        return Vec::new();
449    };
450    let Node::ListLiteral(entries) = &value.node else {
451        return Vec::new();
452    };
453    let mut out = Vec::with_capacity(entries.len());
454    for entry in entries {
455        let Node::DictLiteral(fields) = &entry.node else {
456            continue;
457        };
458        let mut stage = PersonaStageDecl::default();
459        for dict_entry in fields {
460            let Some(key) = entry_key(&dict_entry.key) else {
461                continue;
462            };
463            match key {
464                "name" => {
465                    if let Some(name) = node_string(&dict_entry.value) {
466                        stage.name = name;
467                    }
468                }
469                "allowed_tools" => {
470                    if let Node::ListLiteral(items) = &dict_entry.value.node {
471                        let tools: Vec<String> = items.iter().filter_map(node_string).collect();
472                        stage.allowed_tools = Some(tools);
473                    }
474                }
475                "side_effect_level" => {
476                    stage.side_effect_level = node_string(&dict_entry.value);
477                }
478                "max_iterations" => {
479                    if let Node::IntLiteral(n) = dict_entry.value.node {
480                        if n >= 0 {
481                            stage.max_iterations = Some(n as u32);
482                        }
483                    }
484                }
485                _ => {}
486            }
487        }
488        if !stage.name.is_empty() {
489            out.push(stage);
490        }
491    }
492    out
493}
494
495fn attr_retry(attr: &Attribute) -> Option<PersonaStepRetry> {
496    let retry = attr.named_arg("retry")?;
497    let Node::DictLiteral(entries) = &retry.node else {
498        return None;
499    };
500    for entry in entries {
501        if entry_key(&entry.key) == Some("max_attempts") {
502            if let Node::IntLiteral(value) = entry.value.node {
503                if value >= 1 {
504                    return Some(PersonaStepRetry {
505                        max_attempts: value as u64,
506                    });
507                }
508            }
509        }
510    }
511    None
512}
513
514fn attr_step_budget(attr: &Attribute) -> Option<PersonaStepBudget> {
515    let budget = attr.named_arg("budget")?;
516    let Node::DictLiteral(entries) = &budget.node else {
517        return None;
518    };
519    let mut out = PersonaStepBudget::default();
520    let mut any = false;
521    for entry in entries {
522        match entry_key(&entry.key) {
523            Some("max_tokens") => {
524                if let Node::IntLiteral(value) = entry.value.node {
525                    if value >= 1 {
526                        out.max_tokens = Some(value as u64);
527                        any = true;
528                    }
529                }
530            }
531            Some("max_usd") => match entry.value.node {
532                Node::FloatLiteral(value) if value.is_finite() && value >= 0.0 => {
533                    out.max_usd = Some(value);
534                    any = true;
535                }
536                Node::IntLiteral(value) if value >= 0 => {
537                    out.max_usd = Some(value as f64);
538                    any = true;
539                }
540                _ => {}
541            },
542            _ => {}
543        }
544    }
545    any.then_some(out)
546}
547
548fn entry_key(node: &SNode) -> Option<&str> {
549    match &node.node {
550        Node::Identifier(value) | Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
551            Some(value.as_str())
552        }
553        _ => None,
554    }
555}
556
557fn collect_called_functions(body: &[SNode]) -> Vec<String> {
558    let mut calls = Vec::new();
559    for node in body {
560        collect_called_functions_node(node, &mut calls);
561    }
562    calls
563}
564
565fn collect_called_functions_node(node: &SNode, calls: &mut Vec<String>) {
566    match &node.node {
567        Node::FunctionCall { name, args, .. } => {
568            calls.push(name.clone());
569            collect_many(args, calls);
570        }
571        Node::LetBinding { value, .. }
572        | Node::VarBinding { value, .. }
573        | Node::ReturnStmt { value: Some(value) }
574        | Node::YieldExpr { value: Some(value) }
575        | Node::EmitExpr { value }
576        | Node::ThrowStmt { value }
577        | Node::Spread(value)
578        | Node::TryOperator { operand: value }
579        | Node::TryStar { operand: value }
580        | Node::UnaryOp { operand: value, .. } => collect_called_functions_node(value, calls),
581        Node::IfElse {
582            condition,
583            then_body,
584            else_body,
585        } => {
586            collect_called_functions_node(condition, calls);
587            collect_many(then_body, calls);
588            if let Some(else_body) = else_body {
589                collect_many(else_body, calls);
590            }
591        }
592        Node::ForIn { iterable, body, .. } => {
593            collect_called_functions_node(iterable, calls);
594            collect_many(body, calls);
595        }
596        Node::MatchExpr { value, arms } => {
597            collect_called_functions_node(value, calls);
598            for arm in arms {
599                collect_called_functions_node(&arm.pattern, calls);
600                if let Some(guard) = &arm.guard {
601                    collect_called_functions_node(guard, calls);
602                }
603                collect_many(&arm.body, calls);
604            }
605        }
606        Node::WhileLoop { condition, body } => {
607            collect_called_functions_node(condition, calls);
608            collect_many(body, calls);
609        }
610        Node::Retry { count, body } => {
611            collect_called_functions_node(count, calls);
612            collect_many(body, calls);
613        }
614        Node::CostRoute { options, body } => {
615            for (_, value) in options {
616                collect_called_functions_node(value, calls);
617            }
618            collect_many(body, calls);
619        }
620        Node::TryCatch {
621            has_catch: _,
622            body,
623            catch_body,
624            finally_body,
625            ..
626        } => {
627            collect_many(body, calls);
628            collect_many(catch_body, calls);
629            if let Some(finally_body) = finally_body {
630                collect_many(finally_body, calls);
631            }
632        }
633        Node::TryExpr { body }
634        | Node::SpawnExpr { body }
635        | Node::DeferStmt { body }
636        | Node::MutexBlock { body }
637        | Node::Block(body)
638        | Node::Closure { body, .. } => collect_many(body, calls),
639        Node::DeadlineBlock { duration, body } => {
640            collect_called_functions_node(duration, calls);
641            collect_many(body, calls);
642        }
643        Node::GuardStmt {
644            condition,
645            else_body,
646        } => {
647            collect_called_functions_node(condition, calls);
648            collect_many(else_body, calls);
649        }
650        Node::RequireStmt { condition, message } => {
651            collect_called_functions_node(condition, calls);
652            if let Some(message) = message {
653                collect_called_functions_node(message, calls);
654            }
655        }
656        Node::Parallel {
657            expr,
658            body,
659            options,
660            ..
661        } => {
662            collect_called_functions_node(expr, calls);
663            for (_, value) in options {
664                collect_called_functions_node(value, calls);
665            }
666            collect_many(body, calls);
667        }
668        Node::SelectExpr {
669            cases,
670            timeout,
671            default_body,
672        } => {
673            for case in cases {
674                collect_called_functions_node(&case.channel, calls);
675                collect_many(&case.body, calls);
676            }
677            if let Some((duration, body)) = timeout {
678                collect_called_functions_node(duration, calls);
679                collect_many(body, calls);
680            }
681            if let Some(body) = default_body {
682                collect_many(body, calls);
683            }
684        }
685        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
686            collect_called_functions_node(object, calls);
687            collect_many(args, calls);
688        }
689        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
690            collect_called_functions_node(object, calls);
691        }
692        Node::SubscriptAccess { object, index }
693        | Node::OptionalSubscriptAccess { object, index } => {
694            collect_called_functions_node(object, calls);
695            collect_called_functions_node(index, calls);
696        }
697        Node::SliceAccess { object, start, end } => {
698            collect_called_functions_node(object, calls);
699            if let Some(start) = start {
700                collect_called_functions_node(start, calls);
701            }
702            if let Some(end) = end {
703                collect_called_functions_node(end, calls);
704            }
705        }
706        Node::BinaryOp { left, right, .. } => {
707            collect_called_functions_node(left, calls);
708            collect_called_functions_node(right, calls);
709        }
710        Node::Ternary {
711            condition,
712            true_expr,
713            false_expr,
714        } => {
715            collect_called_functions_node(condition, calls);
716            collect_called_functions_node(true_expr, calls);
717            collect_called_functions_node(false_expr, calls);
718        }
719        Node::Assignment { target, value, .. } => {
720            collect_called_functions_node(target, calls);
721            collect_called_functions_node(value, calls);
722        }
723        Node::EnumConstruct { args, .. } => collect_many(args, calls),
724        Node::StructConstruct { fields, .. } | Node::DictLiteral(fields) => {
725            collect_dict_calls(fields, calls);
726        }
727        Node::ListLiteral(items) | Node::OrPattern(items) => collect_many(items, calls),
728        Node::HitlExpr { args, .. } => {
729            for arg in args {
730                collect_called_functions_node(&arg.value, calls);
731            }
732        }
733        Node::AttributedDecl { inner, .. } => collect_called_functions_node(inner, calls),
734        Node::Pipeline { body, .. }
735        | Node::OverrideDecl { body, .. }
736        | Node::FnDecl { body, .. }
737        | Node::ToolDecl { body, .. } => collect_many(body, calls),
738        Node::SkillDecl { fields, .. } | Node::EvalPackDecl { fields, .. } => {
739            for (_, value) in fields {
740                collect_called_functions_node(value, calls);
741            }
742        }
743        _ => {}
744    }
745}
746
747fn collect_many(nodes: &[SNode], calls: &mut Vec<String>) {
748    for node in nodes {
749        collect_called_functions_node(node, calls);
750    }
751}
752
753fn collect_dict_calls(entries: &[DictEntry], calls: &mut Vec<String>) {
754    for entry in entries {
755        collect_called_functions_node(&entry.key, calls);
756        collect_called_functions_node(&entry.value, calls);
757    }
758}
759
760pub fn validate_persona_manifests(
761    manifest_path: &Path,
762    personas: &[PersonaManifestEntry],
763    context: &PersonaValidationContext,
764) -> Result<(), Vec<PersonaValidationError>> {
765    let mut errors = Vec::new();
766    for (index, persona) in personas.iter().enumerate() {
767        validate_persona(persona, index, manifest_path, context, &mut errors);
768    }
769    if errors.is_empty() {
770        Ok(())
771    } else {
772        Err(errors)
773    }
774}
775
776pub fn validate_persona(
777    persona: &PersonaManifestEntry,
778    index: usize,
779    manifest_path: &Path,
780    context: &PersonaValidationContext,
781    errors: &mut Vec<PersonaValidationError>,
782) {
783    let root = format!("[[personas]][{index}]");
784    for field in persona.extra.keys() {
785        persona_error(
786            manifest_path,
787            format!("{root}.{field}"),
788            "unknown persona field",
789            errors,
790        );
791    }
792    let name = validate_required_string(
793        manifest_path,
794        &root,
795        "name",
796        persona.name.as_deref(),
797        errors,
798    );
799    if let Some(name) = name {
800        validate_tokenish(manifest_path, &root, "name", name, errors);
801    }
802    validate_required_string(
803        manifest_path,
804        &root,
805        "description",
806        persona.description.as_deref(),
807        errors,
808    );
809    validate_required_string(
810        manifest_path,
811        &root,
812        "entry_workflow",
813        persona.entry_workflow.as_deref(),
814        errors,
815    );
816    if persona.tools.is_empty() && persona.capabilities.is_empty() {
817        persona_error(
818            manifest_path,
819            format!("{root}.tools"),
820            "persona requires at least one tool or capability",
821            errors,
822        );
823    }
824    if persona.autonomy_tier.is_none() {
825        persona_error(
826            manifest_path,
827            format!("{root}.autonomy_tier"),
828            "missing required autonomy tier",
829            errors,
830        );
831    }
832    if persona.receipt_policy.is_none() {
833        persona_error(
834            manifest_path,
835            format!("{root}.receipt_policy"),
836            "missing required receipt policy",
837            errors,
838        );
839    }
840    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
841    for tool in &persona.tools {
842        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
843            persona_error(
844                manifest_path,
845                format!("{root}.tools"),
846                format!("unknown tool '{tool}'"),
847                errors,
848            );
849        }
850    }
851    for capability in &persona.capabilities {
852        let Some((cap, op)) = capability.split_once('.') else {
853            persona_error(
854                manifest_path,
855                format!("{root}.capabilities"),
856                format!("capability '{capability}' must use capability.operation syntax"),
857                errors,
858            );
859            continue;
860        };
861        if cap.trim().is_empty() || op.trim().is_empty() {
862            persona_error(
863                manifest_path,
864                format!("{root}.capabilities"),
865                format!("capability '{capability}' must use capability.operation syntax"),
866                errors,
867            );
868        } else if !context.known_capabilities.is_empty()
869            && !context.known_capabilities.contains(capability)
870        {
871            persona_error(
872                manifest_path,
873                format!("{root}.capabilities"),
874                format!("unknown capability '{capability}'"),
875                errors,
876            );
877        }
878    }
879    validate_string_list(
880        manifest_path,
881        &root,
882        "context_packs",
883        &persona.context_packs,
884        errors,
885    );
886    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
887    for schedule in &persona.schedules {
888        if schedule.trim().is_empty() {
889            persona_error(
890                manifest_path,
891                format!("{root}.schedules"),
892                "schedule entries must not be empty",
893                errors,
894            );
895        } else if let Err(error) = croner::Cron::from_str(schedule) {
896            persona_error(
897                manifest_path,
898                format!("{root}.schedules"),
899                format!("invalid cron schedule '{schedule}': {error}"),
900                errors,
901            );
902        }
903    }
904    for trigger in &persona.triggers {
905        match trigger.split_once('.') {
906            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
907            _ => persona_error(
908                manifest_path,
909                format!("{root}.triggers"),
910                format!("trigger '{trigger}' must use provider.event syntax"),
911                errors,
912            ),
913        }
914    }
915    for handoff in &persona.handoffs {
916        if !context.known_names.contains(handoff) {
917            persona_error(
918                manifest_path,
919                format!("{root}.handoffs"),
920                format!("unknown handoff target '{handoff}'"),
921                errors,
922            );
923        }
924    }
925    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
926    validate_persona_stages(manifest_path, &root, persona, context, errors);
927    validate_persona_nested_extra(
928        manifest_path,
929        &root,
930        "model_policy",
931        &persona.model_policy.extra,
932        errors,
933    );
934    validate_persona_nested_extra(
935        manifest_path,
936        &root,
937        "package_source",
938        &persona.package_source.extra,
939        errors,
940    );
941    validate_persona_nested_extra(
942        manifest_path,
943        &root,
944        "rollout_policy",
945        &persona.rollout_policy.extra,
946        errors,
947    );
948    if let Some(percentage) = persona.rollout_policy.percentage {
949        if percentage > 100 {
950            persona_error(
951                manifest_path,
952                format!("{root}.rollout_policy.percentage"),
953                "rollout percentage must be between 0 and 100",
954                errors,
955            );
956        }
957    }
958}
959
960pub fn validate_required_string<'a>(
961    manifest_path: &Path,
962    root: &str,
963    field: &str,
964    value: Option<&'a str>,
965    errors: &mut Vec<PersonaValidationError>,
966) -> Option<&'a str> {
967    match value.map(str::trim) {
968        Some(value) if !value.is_empty() => Some(value),
969        _ => {
970            persona_error(
971                manifest_path,
972                format!("{root}.{field}"),
973                format!("missing required {field}"),
974                errors,
975            );
976            None
977        }
978    }
979}
980
981pub fn validate_string_list(
982    manifest_path: &Path,
983    root: &str,
984    field: &str,
985    values: &[String],
986    errors: &mut Vec<PersonaValidationError>,
987) {
988    for value in values {
989        if value.trim().is_empty() {
990            persona_error(
991                manifest_path,
992                format!("{root}.{field}"),
993                format!("{field} entries must not be empty"),
994                errors,
995            );
996        } else {
997            validate_tokenish(manifest_path, root, field, value, errors);
998        }
999    }
1000}
1001
1002pub fn validate_tokenish(
1003    manifest_path: &Path,
1004    root: &str,
1005    field: &str,
1006    value: &str,
1007    errors: &mut Vec<PersonaValidationError>,
1008) {
1009    if !value
1010        .chars()
1011        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
1012    {
1013        persona_error(
1014            manifest_path,
1015            format!("{root}.{field}"),
1016            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
1017            errors,
1018        );
1019    }
1020}
1021
1022pub fn validate_persona_budget(
1023    manifest_path: &Path,
1024    root: &str,
1025    budget: &PersonaBudget,
1026    errors: &mut Vec<PersonaValidationError>,
1027) {
1028    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
1029    for (field, value) in [
1030        ("daily_usd", budget.daily_usd),
1031        ("hourly_usd", budget.hourly_usd),
1032        ("run_usd", budget.run_usd),
1033    ] {
1034        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
1035            persona_error(
1036                manifest_path,
1037                format!("{root}.budget.{field}"),
1038                "budget amounts must be finite non-negative numbers",
1039                errors,
1040            );
1041        }
1042    }
1043}
1044
1045pub fn validate_persona_nested_extra(
1046    manifest_path: &Path,
1047    root: &str,
1048    field: &str,
1049    extra: &BTreeMap<String, toml::Value>,
1050    errors: &mut Vec<PersonaValidationError>,
1051) {
1052    for key in extra.keys() {
1053        persona_error(
1054            manifest_path,
1055            format!("{root}.{field}.{key}"),
1056            format!("unknown {field} field"),
1057            errors,
1058        );
1059    }
1060}
1061
1062pub fn validate_persona_stages(
1063    manifest_path: &Path,
1064    root: &str,
1065    persona: &PersonaManifestEntry,
1066    context: &PersonaValidationContext,
1067    errors: &mut Vec<PersonaValidationError>,
1068) {
1069    let stage_names: BTreeSet<&str> = persona
1070        .stages
1071        .iter()
1072        .map(|stage| stage.name.as_str())
1073        .collect();
1074    let mut seen = BTreeSet::new();
1075    for (index, stage) in persona.stages.iter().enumerate() {
1076        let field = format!("{root}.stages[{index}]");
1077        if stage.name.trim().is_empty() {
1078            persona_error(
1079                manifest_path,
1080                format!("{field}.name"),
1081                "stage name must not be empty",
1082                errors,
1083            );
1084        } else {
1085            validate_tokenish(manifest_path, &field, "name", &stage.name, errors);
1086            if !seen.insert(stage.name.as_str()) {
1087                persona_error(
1088                    manifest_path,
1089                    format!("{field}.name"),
1090                    format!("duplicate stage name '{}'", stage.name),
1091                    errors,
1092                );
1093            }
1094        }
1095        for key in stage.extra.keys() {
1096            persona_error(
1097                manifest_path,
1098                format!("{field}.{key}"),
1099                "unknown stage field",
1100                errors,
1101            );
1102        }
1103        if let Some(tools) = stage.allowed_tools.as_ref() {
1104            for tool in tools {
1105                if tool.trim().is_empty() {
1106                    persona_error(
1107                        manifest_path,
1108                        format!("{field}.allowed_tools"),
1109                        "allowed_tools entries must not be empty",
1110                        errors,
1111                    );
1112                    continue;
1113                }
1114                if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
1115                    persona_error(
1116                        manifest_path,
1117                        format!("{field}.allowed_tools"),
1118                        format!("unknown tool '{tool}'"),
1119                        errors,
1120                    );
1121                } else if !persona.tools.is_empty() && !persona.tools.contains(tool) {
1122                    persona_error(
1123                        manifest_path,
1124                        format!("{field}.allowed_tools"),
1125                        format!("tool '{tool}' is not part of the persona-level tools allowlist"),
1126                        errors,
1127                    );
1128                }
1129            }
1130        }
1131        if let Some(level) = stage.side_effect_level.as_deref() {
1132            match level {
1133                "none" | "read_only" | "workspace_write" | "process_exec" | "network" => {}
1134                _ => persona_error(
1135                    manifest_path,
1136                    format!("{field}.side_effect_level"),
1137                    format!(
1138                        "unknown side_effect_level '{level}' (expected none, read_only, workspace_write, process_exec, or network)"
1139                    ),
1140                    errors,
1141                ),
1142            }
1143        }
1144        if let Some(exit) = stage.on_exit.as_ref() {
1145            validate_persona_nested_extra(manifest_path, &field, "on_exit", &exit.extra, errors);
1146            for (key, target) in [
1147                ("on_complete", exit.on_complete.as_deref()),
1148                ("on_failure", exit.on_failure.as_deref()),
1149            ] {
1150                let Some(target) = target else { continue };
1151                if !stage_names.contains(target) {
1152                    persona_error(
1153                        manifest_path,
1154                        format!("{field}.on_exit.{key}"),
1155                        format!("unknown stage '{target}'"),
1156                        errors,
1157                    );
1158                }
1159            }
1160        }
1161    }
1162}
1163
1164pub fn persona_error(
1165    manifest_path: &Path,
1166    field_path: String,
1167    message: impl Into<String>,
1168    errors: &mut Vec<PersonaValidationError>,
1169) {
1170    errors.push(PersonaValidationError {
1171        manifest_path: manifest_path.to_path_buf(),
1172        field_path,
1173        message: message.into(),
1174    });
1175}
1176
1177pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
1178    BTreeMap::from([
1179        (
1180            "workspace",
1181            vec![
1182                "read_text",
1183                "write_text",
1184                "apply_edit",
1185                "delete",
1186                "exists",
1187                "file_exists",
1188                "list",
1189                "project_root",
1190                "roots",
1191            ],
1192        ),
1193        ("process", vec!["exec"]),
1194        ("template", vec!["render"]),
1195        ("interaction", vec!["ask"]),
1196        (
1197            "runtime",
1198            vec![
1199                "approved_plan",
1200                "dry_run",
1201                "pipeline_input",
1202                "record_run",
1203                "set_result",
1204                "task",
1205            ],
1206        ),
1207        (
1208            "project",
1209            vec![
1210                "agent_instructions",
1211                "code_patterns",
1212                "compute_content_hash",
1213                "ide_context",
1214                "lessons",
1215                "mcp_config",
1216                "metadata_get",
1217                "metadata_refresh_hashes",
1218                "metadata_save",
1219                "metadata_set",
1220                "metadata_stale",
1221                "scan",
1222                "scope_test_command",
1223                "test_commands",
1224            ],
1225        ),
1226        (
1227            "session",
1228            vec![
1229                "active_roots",
1230                "changed_paths",
1231                "preread_get",
1232                "preread_read_many",
1233            ],
1234        ),
1235        (
1236            "editor",
1237            vec!["get_active_file", "get_selection", "get_visible_files"],
1238        ),
1239        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
1240        ("git", vec!["get_branch", "get_diff"]),
1241        ("learning", vec!["get_learned_rules", "report_correction"]),
1242    ])
1243}
1244
1245pub fn default_persona_capabilities() -> BTreeSet<String> {
1246    let mut capabilities = BTreeSet::new();
1247    for (capability, operations) in default_persona_capability_map() {
1248        for operation in operations {
1249            capabilities.insert(format!("{capability}.{operation}"));
1250        }
1251    }
1252    capabilities
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    fn context(names: &[&str]) -> PersonaValidationContext {
1260        PersonaValidationContext {
1261            known_capabilities: default_persona_capabilities(),
1262            known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
1263            known_names: names.iter().map(|name| name.to_string()).collect(),
1264        }
1265    }
1266
1267    #[test]
1268    fn validates_sample_manifest() {
1269        let parsed = parse_persona_manifest_str(
1270            r#"
1271[[personas]]
1272name = "merge_captain"
1273description = "Owns PR readiness."
1274entry_workflow = "workflows/merge_captain.harn#run"
1275tools = ["github", "ci"]
1276capabilities = ["git.get_diff"]
1277autonomy = "act_with_approval"
1278receipts = "required"
1279triggers = ["github.pr_opened"]
1280schedules = ["*/30 * * * *"]
1281handoffs = ["review_captain"]
1282context_packs = ["repo_policy"]
1283evals = ["merge_safety"]
1284budget = { daily_usd = 20.0 }
1285
1286[[personas]]
1287name = "review_captain"
1288description = "Reviews code."
1289entry_workflow = "workflows/review_captain.harn#run"
1290tools = ["github"]
1291autonomy_tier = "suggest"
1292receipt_policy = "optional"
1293"#,
1294        )
1295        .expect("manifest parses");
1296
1297        validate_persona_manifests(
1298            Path::new("harn.toml"),
1299            &parsed.personas,
1300            &context(&["merge_captain", "review_captain"]),
1301        )
1302        .expect("manifest validates");
1303    }
1304
1305    #[test]
1306    fn bad_manifest_produces_typed_errors() {
1307        let parsed = parse_persona_manifest_str(
1308            r#"
1309[[personas]]
1310name = "bad"
1311description = ""
1312entry_workflow = ""
1313tools = ["unknown"]
1314capabilities = ["git"]
1315autonomy = "shadow"
1316receipts = "required"
1317triggers = ["github"]
1318schedules = [""]
1319handoffs = ["missing"]
1320budget = { daily_usd = -1.0, surprise = true }
1321surprise = true
1322"#,
1323        )
1324        .expect("manifest parses");
1325
1326        let errors = validate_persona_manifests(
1327            Path::new("harn.toml"),
1328            &parsed.personas,
1329            &context(&["bad"]),
1330        )
1331        .expect_err("manifest rejects");
1332        let fields: BTreeSet<_> = errors
1333            .iter()
1334            .map(|error| error.field_path.as_str())
1335            .collect();
1336        assert!(fields.contains("[[personas]][0].description"));
1337        assert!(fields.contains("[[personas]][0].entry_workflow"));
1338        assert!(fields.contains("[[personas]][0].tools"));
1339        assert!(fields.contains("[[personas]][0].capabilities"));
1340        assert!(fields.contains("[[personas]][0].triggers"));
1341        assert!(fields.contains("[[personas]][0].schedules"));
1342        assert!(fields.contains("[[personas]][0].handoffs"));
1343        assert!(fields.contains("[[personas]][0].budget.daily_usd"));
1344        assert!(fields.contains("[[personas]][0].budget.surprise"));
1345        assert!(fields.contains("[[personas]][0].surprise"));
1346    }
1347
1348    #[test]
1349    fn manifest_stages_round_trip_through_serde() {
1350        let parsed = parse_persona_manifest_str(
1351            r#"
1352[[personas]]
1353name = "scoped"
1354description = "Per-stage scoping demo."
1355entry_workflow = "workflows/scoped.harn#run"
1356tools = ["github", "ci"]
1357autonomy = "act_with_approval"
1358receipts = "required"
1359
1360[[personas.stages]]
1361name = "research"
1362allowed_tools = ["github"]
1363side_effect_level = "read_only"
1364
1365[[personas.stages]]
1366name = "act"
1367allowed_tools = ["github", "ci"]
1368side_effect_level = "process_exec"
1369max_iterations = 4
1370on_exit = { on_complete = "research" }
1371"#,
1372        )
1373        .expect("manifest parses");
1374
1375        validate_persona_manifests(
1376            Path::new("harn.toml"),
1377            &parsed.personas,
1378            &context(&["scoped"]),
1379        )
1380        .expect("stage-scoped manifest validates");
1381        let persona = &parsed.personas[0];
1382        assert_eq!(persona.stages.len(), 2);
1383        assert_eq!(persona.stages[0].name, "research");
1384        assert_eq!(
1385            persona.stages[0].allowed_tools.as_deref(),
1386            Some(["github".to_string()].as_slice())
1387        );
1388        assert_eq!(
1389            persona.stages[1]
1390                .on_exit
1391                .as_ref()
1392                .unwrap()
1393                .on_complete
1394                .as_deref(),
1395            Some("research")
1396        );
1397
1398        // Round-trip via the TOML serializer to ensure the shape is stable.
1399        let serialised = toml::to_string(&PersonaManifestDocument {
1400            personas: parsed.personas.clone(),
1401        })
1402        .expect("serialize");
1403        let reparsed = parse_persona_manifest_str(&serialised).expect("reparse");
1404        assert_eq!(reparsed.personas, parsed.personas);
1405    }
1406
1407    #[test]
1408    fn stage_validation_flags_unknown_targets_and_levels() {
1409        let parsed = parse_persona_manifest_str(
1410            r#"
1411[[personas]]
1412name = "scoped"
1413description = "Bad stages."
1414entry_workflow = "workflows/scoped.harn#run"
1415tools = ["github"]
1416autonomy = "suggest"
1417receipts = "optional"
1418
1419[[personas.stages]]
1420name = "research"
1421allowed_tools = ["ci"]
1422side_effect_level = "do_anything"
1423on_exit = { on_complete = "missing" }
1424
1425[[personas.stages]]
1426name = "research"
1427"#,
1428        )
1429        .expect("manifest parses");
1430
1431        let errors = validate_persona_manifests(
1432            Path::new("harn.toml"),
1433            &parsed.personas,
1434            &context(&["scoped"]),
1435        )
1436        .expect_err("rejects bad stage config");
1437        let fields: BTreeSet<_> = errors
1438            .iter()
1439            .map(|error| error.field_path.as_str())
1440            .collect();
1441        assert!(fields.contains("[[personas]][0].stages[0].allowed_tools"));
1442        assert!(fields.contains("[[personas]][0].stages[0].side_effect_level"));
1443        assert!(fields.contains("[[personas]][0].stages[0].on_exit.on_complete"));
1444        assert!(fields.contains("[[personas]][0].stages[1].name"));
1445    }
1446
1447    #[test]
1448    fn source_persona_picks_up_stage_attributes() {
1449        let parsed = parse_persona_source_str(
1450            r#"
1451@persona(name: "scoped", tools: [github, ci], stages: [
1452  {name: "research", allowed_tools: [github]},
1453  {name: "act", allowed_tools: [github, ci], side_effect_level: "process_exec"},
1454])
1455fn scoped(ctx) {
1456  research(ctx)
1457  act(ctx)
1458}
1459
1460@step(name: "research") fn research(ctx) { return ctx }
1461@step(name: "act") fn act(ctx) { return ctx }
1462"#,
1463        )
1464        .expect("source persona parses");
1465
1466        let persona = &parsed.personas[0];
1467        assert_eq!(persona.stages.len(), 2);
1468        assert_eq!(persona.stages[0].name, "research");
1469        assert_eq!(
1470            persona.stages[0].allowed_tools.as_deref(),
1471            Some(["github".to_string()].as_slice()),
1472        );
1473        assert_eq!(
1474            persona.stages[1].side_effect_level.as_deref(),
1475            Some("process_exec"),
1476        );
1477    }
1478
1479    #[test]
1480    fn source_persona_extracts_called_steps_in_order() {
1481        let parsed = parse_persona_source_str(
1482            r#"
1483@persona(name: "merge_captain")
1484fn merge_captain(ctx) {
1485  plan(ctx)
1486  verify(ctx)
1487}
1488
1489@step(name: "plan", model: "gpt-5.4-mini", retry: {max_attempts: 2})
1490fn plan(ctx) {
1491  return ctx
1492}
1493
1494@step(name: "verify", error_boundary: continue)
1495fn verify(ctx) {
1496  return ctx
1497}
1498"#,
1499        )
1500        .expect("source persona parses");
1501        assert_eq!(parsed.personas.len(), 1);
1502        let persona = &parsed.personas[0];
1503        assert_eq!(persona.name.as_deref(), Some("merge_captain"));
1504        assert_eq!(persona.steps.len(), 2);
1505        assert_eq!(persona.steps[0].name, "plan");
1506        assert_eq!(persona.steps[0].model.as_deref(), Some("gpt-5.4-mini"));
1507        assert_eq!(persona.steps[0].retry.as_ref().unwrap().max_attempts, 2);
1508        assert_eq!(persona.steps[1].error_boundary.as_deref(), Some("continue"));
1509    }
1510}