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