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