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 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::ValueCall { callee, args } => {
632            collect_called_functions_node(callee, calls);
633            collect_many(args, calls);
634        }
635        Node::LetBinding { value, .. }
636        | Node::ConstBinding { value, .. }
637        | Node::ReturnStmt { value: Some(value) }
638        | Node::YieldExpr { value: Some(value) }
639        | Node::EmitExpr { value }
640        | Node::ThrowStmt { value }
641        | Node::Spread(value)
642        | Node::TryOperator { operand: value }
643        | Node::TryStar { operand: value }
644        | Node::UnaryOp { operand: value, .. } => collect_called_functions_node(value, calls),
645        Node::IfElse {
646            condition,
647            then_body,
648            else_body,
649            ..
650        } => {
651            collect_called_functions_node(condition, calls);
652            collect_many(then_body, calls);
653            if let Some(else_body) = else_body {
654                collect_many(else_body, calls);
655            }
656        }
657        Node::ForIn { iterable, body, .. } => {
658            collect_called_functions_node(iterable, calls);
659            collect_many(body, calls);
660        }
661        Node::MatchExpr { value, arms } => {
662            collect_called_functions_node(value, calls);
663            for arm in arms {
664                collect_called_functions_node(&arm.pattern, calls);
665                if let Some(guard) = &arm.guard {
666                    collect_called_functions_node(guard, calls);
667                }
668                collect_many(&arm.body, calls);
669            }
670        }
671        Node::WhileLoop { condition, body } => {
672            collect_called_functions_node(condition, calls);
673            collect_many(body, calls);
674        }
675        Node::Retry { count, body } => {
676            collect_called_functions_node(count, calls);
677            collect_many(body, calls);
678        }
679        Node::CostRoute { options, body } => {
680            for (_, value) in options {
681                collect_called_functions_node(value, calls);
682            }
683            collect_many(body, calls);
684        }
685        Node::TryCatch {
686            has_catch: _,
687            body,
688            catch_body,
689            finally_body,
690            ..
691        } => {
692            collect_many(body, calls);
693            collect_many(catch_body, calls);
694            if let Some(finally_body) = finally_body {
695                collect_many(finally_body, calls);
696            }
697        }
698        Node::TryExpr { body }
699        | Node::SpawnExpr { body }
700        | Node::DeferStmt { body }
701        | Node::MutexBlock { body, .. }
702        | Node::Block(body)
703        | Node::Closure { body, .. } => collect_many(body, calls),
704        Node::DeadlineBlock { duration, body } => {
705            collect_called_functions_node(duration, calls);
706            collect_many(body, calls);
707        }
708        Node::GuardStmt {
709            condition,
710            else_body,
711        } => {
712            collect_called_functions_node(condition, calls);
713            collect_many(else_body, calls);
714        }
715        Node::RequireStmt { condition, message } => {
716            collect_called_functions_node(condition, calls);
717            if let Some(message) = message {
718                collect_called_functions_node(message, calls);
719            }
720        }
721        Node::Parallel {
722            expr,
723            body,
724            options,
725            ..
726        } => {
727            collect_called_functions_node(expr, calls);
728            for (_, value) in options {
729                collect_called_functions_node(value, calls);
730            }
731            collect_many(body, calls);
732        }
733        Node::SelectExpr {
734            cases,
735            timeout,
736            default_body,
737        } => {
738            for case in cases {
739                collect_called_functions_node(&case.channel, calls);
740                collect_many(&case.body, calls);
741            }
742            if let Some((duration, body)) = timeout {
743                collect_called_functions_node(duration, calls);
744                collect_many(body, calls);
745            }
746            if let Some(body) = default_body {
747                collect_many(body, calls);
748            }
749        }
750        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
751            collect_called_functions_node(object, calls);
752            collect_many(args, calls);
753        }
754        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
755            collect_called_functions_node(object, calls);
756        }
757        Node::SubscriptAccess { object, index }
758        | Node::OptionalSubscriptAccess { object, index } => {
759            collect_called_functions_node(object, calls);
760            collect_called_functions_node(index, calls);
761        }
762        Node::SliceAccess { object, start, end } => {
763            collect_called_functions_node(object, calls);
764            if let Some(start) = start {
765                collect_called_functions_node(start, calls);
766            }
767            if let Some(end) = end {
768                collect_called_functions_node(end, calls);
769            }
770        }
771        Node::BinaryOp { left, right, .. } => {
772            collect_called_functions_node(left, calls);
773            collect_called_functions_node(right, calls);
774        }
775        Node::Ternary {
776            condition,
777            true_expr,
778            false_expr,
779        } => {
780            collect_called_functions_node(condition, calls);
781            collect_called_functions_node(true_expr, calls);
782            collect_called_functions_node(false_expr, calls);
783        }
784        Node::Assignment { target, value, .. } => {
785            collect_called_functions_node(target, calls);
786            collect_called_functions_node(value, calls);
787        }
788        Node::EnumConstruct { args, .. } => collect_many(args, calls),
789        Node::StructConstruct { fields, .. } | Node::DictLiteral(fields) => {
790            collect_dict_calls(fields, calls);
791        }
792        Node::ListLiteral(items) | Node::OrPattern(items) => collect_many(items, calls),
793        Node::HitlExpr { args, .. } => {
794            for arg in args {
795                collect_called_functions_node(&arg.value, calls);
796            }
797        }
798        Node::AttributedDecl { inner, .. } => collect_called_functions_node(inner, calls),
799        Node::Pipeline { body, .. }
800        | Node::OverrideDecl { body, .. }
801        | Node::FnDecl { body, .. }
802        | Node::ToolDecl { body, .. } => collect_many(body, calls),
803        Node::SkillDecl { fields, .. } | Node::EvalPackDecl { fields, .. } => {
804            for (_, value) in fields {
805                collect_called_functions_node(value, calls);
806            }
807        }
808        _ => {}
809    }
810}
811
812fn collect_many(nodes: &[SNode], calls: &mut Vec<String>) {
813    for node in nodes {
814        collect_called_functions_node(node, calls);
815    }
816}
817
818fn collect_dict_calls(entries: &[DictEntry], calls: &mut Vec<String>) {
819    for entry in entries {
820        collect_called_functions_node(&entry.key, calls);
821        collect_called_functions_node(&entry.value, calls);
822    }
823}
824
825pub fn validate_persona_manifests(
826    manifest_path: &Path,
827    personas: &[PersonaManifestEntry],
828    context: &PersonaValidationContext,
829) -> Result<(), Vec<PersonaValidationError>> {
830    let mut errors = Vec::new();
831    for (index, persona) in personas.iter().enumerate() {
832        validate_persona(persona, index, manifest_path, context, &mut errors);
833    }
834    if errors.is_empty() {
835        Ok(())
836    } else {
837        Err(errors)
838    }
839}
840
841pub fn validate_persona(
842    persona: &PersonaManifestEntry,
843    index: usize,
844    manifest_path: &Path,
845    context: &PersonaValidationContext,
846    errors: &mut Vec<PersonaValidationError>,
847) {
848    let root = format!("[[personas]][{index}]");
849    for field in persona.extra.keys() {
850        persona_error(
851            manifest_path,
852            format!("{root}.{field}"),
853            "unknown persona field",
854            errors,
855        );
856    }
857    let name = validate_required_string(
858        manifest_path,
859        &root,
860        "name",
861        persona.name.as_deref(),
862        errors,
863    );
864    if let Some(name) = name {
865        validate_tokenish(manifest_path, &root, "name", name, errors);
866    }
867    validate_required_string(
868        manifest_path,
869        &root,
870        "description",
871        persona.description.as_deref(),
872        errors,
873    );
874    validate_required_string(
875        manifest_path,
876        &root,
877        "entry_workflow",
878        persona.entry_workflow.as_deref(),
879        errors,
880    );
881    if persona.tools.is_empty() && persona.capabilities.is_empty() {
882        persona_error(
883            manifest_path,
884            format!("{root}.tools"),
885            "persona requires at least one tool or capability",
886            errors,
887        );
888    }
889    if persona.autonomy_tier.is_none() {
890        persona_error(
891            manifest_path,
892            format!("{root}.autonomy_tier"),
893            "missing required autonomy tier",
894            errors,
895        );
896    }
897    if persona.receipt_policy.is_none() {
898        persona_error(
899            manifest_path,
900            format!("{root}.receipt_policy"),
901            "missing required receipt policy",
902            errors,
903        );
904    }
905    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
906    for tool in &persona.tools {
907        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
908            persona_error(
909                manifest_path,
910                format!("{root}.tools"),
911                format!("unknown tool '{tool}'"),
912                errors,
913            );
914        }
915    }
916    for capability in &persona.capabilities {
917        let Some((cap, op)) = capability.split_once('.') else {
918            persona_error(
919                manifest_path,
920                format!("{root}.capabilities"),
921                format!("capability '{capability}' must use capability.operation syntax"),
922                errors,
923            );
924            continue;
925        };
926        if cap.trim().is_empty() || op.trim().is_empty() {
927            persona_error(
928                manifest_path,
929                format!("{root}.capabilities"),
930                format!("capability '{capability}' must use capability.operation syntax"),
931                errors,
932            );
933        } else if !context.known_capabilities.is_empty()
934            && !context.known_capabilities.contains(capability)
935        {
936            persona_error(
937                manifest_path,
938                format!("{root}.capabilities"),
939                format!("unknown capability '{capability}'"),
940                errors,
941            );
942        }
943    }
944    validate_string_list(
945        manifest_path,
946        &root,
947        "context_packs",
948        &persona.context_packs,
949        errors,
950    );
951    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
952    for schedule in &persona.schedules {
953        if schedule.trim().is_empty() {
954            persona_error(
955                manifest_path,
956                format!("{root}.schedules"),
957                "schedule entries must not be empty",
958                errors,
959            );
960        } else if let Err(error) = croner::Cron::from_str(schedule) {
961            persona_error(
962                manifest_path,
963                format!("{root}.schedules"),
964                format!("invalid cron schedule '{schedule}': {error}"),
965                errors,
966            );
967        }
968    }
969    for trigger in &persona.triggers {
970        match trigger.split_once('.') {
971            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
972            _ => persona_error(
973                manifest_path,
974                format!("{root}.triggers"),
975                format!("trigger '{trigger}' must use provider.event syntax"),
976                errors,
977            ),
978        }
979    }
980    for handoff in &persona.handoffs {
981        if !context.known_names.contains(handoff) {
982            persona_error(
983                manifest_path,
984                format!("{root}.handoffs"),
985                format!("unknown handoff target '{handoff}'"),
986                errors,
987            );
988        }
989    }
990    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
991    validate_persona_stages(manifest_path, &root, persona, context, errors);
992    validate_persona_nested_extra(
993        manifest_path,
994        &root,
995        "model_policy",
996        &persona.model_policy.extra,
997        errors,
998    );
999    validate_persona_nested_extra(
1000        manifest_path,
1001        &root,
1002        "package_source",
1003        &persona.package_source.extra,
1004        errors,
1005    );
1006    validate_persona_nested_extra(
1007        manifest_path,
1008        &root,
1009        "rollout_policy",
1010        &persona.rollout_policy.extra,
1011        errors,
1012    );
1013    if let Some(percentage) = persona.rollout_policy.percentage {
1014        if percentage > 100 {
1015            persona_error(
1016                manifest_path,
1017                format!("{root}.rollout_policy.percentage"),
1018                "rollout percentage must be between 0 and 100",
1019                errors,
1020            );
1021        }
1022    }
1023}
1024
1025pub fn validate_required_string<'a>(
1026    manifest_path: &Path,
1027    root: &str,
1028    field: &str,
1029    value: Option<&'a str>,
1030    errors: &mut Vec<PersonaValidationError>,
1031) -> Option<&'a str> {
1032    match value.map(str::trim) {
1033        Some(value) if !value.is_empty() => Some(value),
1034        _ => {
1035            persona_error(
1036                manifest_path,
1037                format!("{root}.{field}"),
1038                format!("missing required {field}"),
1039                errors,
1040            );
1041            None
1042        }
1043    }
1044}
1045
1046pub fn validate_string_list(
1047    manifest_path: &Path,
1048    root: &str,
1049    field: &str,
1050    values: &[String],
1051    errors: &mut Vec<PersonaValidationError>,
1052) {
1053    for value in values {
1054        if value.trim().is_empty() {
1055            persona_error(
1056                manifest_path,
1057                format!("{root}.{field}"),
1058                format!("{field} entries must not be empty"),
1059                errors,
1060            );
1061        } else {
1062            validate_tokenish(manifest_path, root, field, value, errors);
1063        }
1064    }
1065}
1066
1067pub fn validate_tokenish(
1068    manifest_path: &Path,
1069    root: &str,
1070    field: &str,
1071    value: &str,
1072    errors: &mut Vec<PersonaValidationError>,
1073) {
1074    if !value
1075        .chars()
1076        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
1077    {
1078        persona_error(
1079            manifest_path,
1080            format!("{root}.{field}"),
1081            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
1082            errors,
1083        );
1084    }
1085}
1086
1087pub fn validate_persona_budget(
1088    manifest_path: &Path,
1089    root: &str,
1090    budget: &PersonaBudget,
1091    errors: &mut Vec<PersonaValidationError>,
1092) {
1093    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
1094    for (field, value) in [
1095        ("daily_usd", budget.daily_usd),
1096        ("hourly_usd", budget.hourly_usd),
1097        ("run_usd", budget.run_usd),
1098    ] {
1099        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
1100            persona_error(
1101                manifest_path,
1102                format!("{root}.budget.{field}"),
1103                "budget amounts must be finite non-negative numbers",
1104                errors,
1105            );
1106        }
1107    }
1108}
1109
1110pub fn validate_persona_nested_extra(
1111    manifest_path: &Path,
1112    root: &str,
1113    field: &str,
1114    extra: &BTreeMap<String, toml::Value>,
1115    errors: &mut Vec<PersonaValidationError>,
1116) {
1117    for key in extra.keys() {
1118        persona_error(
1119            manifest_path,
1120            format!("{root}.{field}.{key}"),
1121            format!("unknown {field} field"),
1122            errors,
1123        );
1124    }
1125}
1126
1127pub fn validate_persona_stages(
1128    manifest_path: &Path,
1129    root: &str,
1130    persona: &PersonaManifestEntry,
1131    context: &PersonaValidationContext,
1132    errors: &mut Vec<PersonaValidationError>,
1133) {
1134    let stage_names: BTreeSet<&str> = persona
1135        .stages
1136        .iter()
1137        .map(|stage| stage.name.as_str())
1138        .collect();
1139    let mut seen = BTreeSet::new();
1140    for (index, stage) in persona.stages.iter().enumerate() {
1141        let field = format!("{root}.stages[{index}]");
1142        if stage.name.trim().is_empty() {
1143            persona_error(
1144                manifest_path,
1145                format!("{field}.name"),
1146                "stage name must not be empty",
1147                errors,
1148            );
1149        } else {
1150            validate_tokenish(manifest_path, &field, "name", &stage.name, errors);
1151            if !seen.insert(stage.name.as_str()) {
1152                persona_error(
1153                    manifest_path,
1154                    format!("{field}.name"),
1155                    format!("duplicate stage name '{}'", stage.name),
1156                    errors,
1157                );
1158            }
1159        }
1160        for key in stage.extra.keys() {
1161            persona_error(
1162                manifest_path,
1163                format!("{field}.{key}"),
1164                "unknown stage field",
1165                errors,
1166            );
1167        }
1168        if let Some(tools) = stage.allowed_tools.as_ref() {
1169            for tool in tools {
1170                if tool.trim().is_empty() {
1171                    persona_error(
1172                        manifest_path,
1173                        format!("{field}.allowed_tools"),
1174                        "allowed_tools entries must not be empty",
1175                        errors,
1176                    );
1177                    continue;
1178                }
1179                if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
1180                    persona_error(
1181                        manifest_path,
1182                        format!("{field}.allowed_tools"),
1183                        format!("unknown tool '{tool}'"),
1184                        errors,
1185                    );
1186                } else if !persona.tools.is_empty() && !persona.tools.contains(tool) {
1187                    persona_error(
1188                        manifest_path,
1189                        format!("{field}.allowed_tools"),
1190                        format!("tool '{tool}' is not part of the persona-level tools allowlist"),
1191                        errors,
1192                    );
1193                }
1194            }
1195        }
1196        if let Some(level) = stage.side_effect_level.as_deref() {
1197            match level {
1198                "none" | "read_only" | "workspace_write" | "process_exec" | "network" => {}
1199                _ => persona_error(
1200                    manifest_path,
1201                    format!("{field}.side_effect_level"),
1202                    format!(
1203                        "unknown side_effect_level '{level}' (expected none, read_only, workspace_write, process_exec, or network)"
1204                    ),
1205                    errors,
1206                ),
1207            }
1208        }
1209        if let Some(exit) = stage.on_exit.as_ref() {
1210            validate_persona_nested_extra(manifest_path, &field, "on_exit", &exit.extra, errors);
1211            for (key, target) in [
1212                ("on_complete", exit.on_complete.as_deref()),
1213                ("on_failure", exit.on_failure.as_deref()),
1214            ] {
1215                let Some(target) = target else { continue };
1216                if !stage_names.contains(target) {
1217                    persona_error(
1218                        manifest_path,
1219                        format!("{field}.on_exit.{key}"),
1220                        format!("unknown stage '{target}'"),
1221                        errors,
1222                    );
1223                }
1224            }
1225        }
1226    }
1227}
1228
1229pub fn persona_error(
1230    manifest_path: &Path,
1231    field_path: String,
1232    message: impl Into<String>,
1233    errors: &mut Vec<PersonaValidationError>,
1234) {
1235    errors.push(PersonaValidationError {
1236        manifest_path: manifest_path.to_path_buf(),
1237        field_path,
1238        message: message.into(),
1239    });
1240}
1241
1242pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
1243    BTreeMap::from([
1244        (
1245            "workspace",
1246            vec![
1247                "read_text",
1248                "write_text",
1249                "apply_edit",
1250                "delete",
1251                "exists",
1252                "file_exists",
1253                "list",
1254                "project_root",
1255                "roots",
1256            ],
1257        ),
1258        ("process", vec!["exec"]),
1259        ("template", vec!["render"]),
1260        ("interaction", vec!["ask"]),
1261        (
1262            "runtime",
1263            vec![
1264                "approved_plan",
1265                "dry_run",
1266                "pipeline_input",
1267                "record_run",
1268                "set_result",
1269                "task",
1270            ],
1271        ),
1272        (
1273            "project",
1274            vec![
1275                "agent_instructions",
1276                "code_patterns",
1277                "compute_content_hash",
1278                "ide_context",
1279                "lessons",
1280                "mcp_config",
1281                "metadata_get",
1282                "metadata_inspect",
1283                "metadata_refresh_hashes",
1284                "metadata_save",
1285                "metadata_set",
1286                "metadata_stale",
1287                "path_metadata_entries",
1288                "path_metadata_get",
1289                "path_metadata_set",
1290                "scan",
1291                "scope_test_command",
1292                "test_commands",
1293            ],
1294        ),
1295        (
1296            "session",
1297            vec![
1298                "active_roots",
1299                "changed_paths",
1300                "preread_get",
1301                "preread_read_many",
1302            ],
1303        ),
1304        (
1305            "editor",
1306            vec!["get_active_file", "get_selection", "get_visible_files"],
1307        ),
1308        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
1309        ("git", vec!["get_branch", "get_diff"]),
1310        ("learning", vec!["get_learned_rules", "report_correction"]),
1311    ])
1312}
1313
1314pub fn default_persona_capabilities() -> BTreeSet<String> {
1315    let mut capabilities = BTreeSet::new();
1316    for (capability, operations) in default_persona_capability_map() {
1317        for operation in operations {
1318            capabilities.insert(format!("{capability}.{operation}"));
1319        }
1320    }
1321    capabilities
1322}
1323
1324#[cfg(test)]
1325#[path = "personas_tests.rs"]
1326mod tests;