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