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