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