1use crate::blueprint::{
31 resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint, BlueprintMetadata,
32 BoundAgent, BoundAgentResolveError, Runner,
33};
34use crate::core::ctx::Ctx;
35use crate::core::engine::Engine;
36use crate::core::projection_placement::{ProjectionPlacement, ProjectionPlacementError};
37use crate::core::step_naming::{StepNaming, StepNamingError};
38use crate::operator::{Operator, OperatorSpawner, WorkerBinding};
39use crate::types::{CapToken, StepId};
40use crate::worker::adapter::{InProcSpawner, SpawnError, SpawnerAdapter, WorkerFn};
41use crate::worker::process_spawner::{ProcessSpawner, StreamMode};
42use crate::worker::Worker;
43use async_trait::async_trait;
44use mlua_flow_ir::{Expr, Node as FlowNode, Path};
45use mlua_swarm_schema::{VerdictChannel, VerdictContract};
46use serde_json::Value;
47use std::collections::HashMap;
48use std::sync::Arc;
49use thiserror::Error;
50
51#[derive(Debug, Error)]
56pub enum CompileError {
57 #[error("bound agent resolution: {0}")]
59 BoundAgent(#[from] BoundAgentResolveError),
60 #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
63 UnknownKind(AgentKind),
64 #[error("agent '{name}' spec invalid: {msg}")]
67 InvalidSpec {
68 name: String,
70 msg: String,
72 },
73 #[error("flow references agent '{0}' but no AgentDef matches")]
76 UnresolvedRef(String),
77 #[error("duplicate AgentDef name: {0}")]
79 DuplicateAgent(String),
80 #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
83 UnresolvedOperatorRef {
84 agent: String,
86 op_ref: String,
88 defined: Vec<String>,
91 },
92 #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
96 UnresolvedMetaRef {
97 where_: String,
101 meta_ref: String,
103 defined: Vec<String>,
106 },
107 #[error("StepNaming collision: {0}")]
113 StepNamingCollision(#[from] StepNamingError),
114 #[error("invalid projection_placement: {0}")]
121 InvalidProjectionPlacement(#[from] ProjectionPlacementError),
122 #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
127 UnresolvedAuditAgent {
128 agent: String,
130 defined: Vec<String>,
133 },
134 #[error(
143 "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
144 addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
145 BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
146 )]
147 VerdictChannelMismatch {
148 where_: String,
151 agent: String,
153 expected_channel: String,
155 actual_shape: String,
158 },
159 #[error(
164 "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
165 values {values:?}"
166 )]
167 VerdictValueNotInContract {
168 where_: String,
171 agent: String,
174 value: String,
179 values: Vec<String>,
182 },
183 #[error(
195 "agent '{agent}' declares verdict value '{value}' but no downstream Branch/Loop \
196 cond references it (declared: {declared_values:?}, at step '{step_ref}') — either \
197 handle the value downstream or drop it from `verdict.values`"
198 )]
199 VerdictValueUnhandled {
200 agent: String,
203 value: String,
205 declared_values: Vec<String>,
208 step_ref: String,
213 },
214}
215
216pub trait SpawnerFactory: Send + Sync {
228 fn build(
231 &self,
232 agent_def: &AgentDef,
233 hint: Option<&Value>,
234 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
235}
236
237pub trait SpawnerFactoryKind: SpawnerFactory {
253 const KIND: AgentKind;
256 type Worker: crate::worker::Worker;
263}
264
265#[derive(Clone)]
268pub struct SpawnerRegistry {
269 factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
270}
271
272impl SpawnerRegistry {
273 pub fn new() -> Self {
275 Self {
276 factories: HashMap::new(),
277 }
278 }
279 pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
288 let f: Arc<dyn SpawnerFactory> = factory;
289 self.factories.insert(F::KIND, f);
290 self
291 }
292}
293
294impl Default for SpawnerRegistry {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300pub struct Compiler {
307 registry: SpawnerRegistry,
308 default_spawner: Option<Arc<dyn SpawnerAdapter>>,
309}
310
311pub struct CompiledBlueprint {
315 pub router: Arc<CompiledAgentTable>,
317 pub flow: FlowNode,
319 pub metadata: BlueprintMetadata,
321 pub step_naming: Arc<StepNaming>,
326 pub projection_placement: Arc<ProjectionPlacement>,
332}
333
334fn project_bound_agent_for_legacy_factories(bound: &BoundAgent) -> AgentDef {
335 let mut agent = bound.agent.clone();
336 match &bound.runner {
337 Some(Runner::WsOperator { variant, tools })
338 | Some(Runner::WsClaudeCode { variant, tools }) => {
339 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
340 profile.worker_binding = Some(variant.clone());
341 profile.tools = tools.clone();
342 }
343 Some(Runner::AgentBlockInProcess { tools }) => {
344 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
345 profile.worker_binding = None;
346 profile.tools = tools.clone();
347 }
348 None => {}
349 }
350 let meta = agent.meta.get_or_insert_with(Default::default);
351 meta.context_policy = bound.context_policy.clone();
352 agent
353}
354
355pub(crate) fn materialize_bound_blueprint(
358 bp: &Blueprint,
359 bound_agents: &[BoundAgent],
360) -> Blueprint {
361 let mut effective = bp.clone();
362 effective.agents = bound_agents
363 .iter()
364 .map(project_bound_agent_for_legacy_factories)
365 .collect();
366 effective.default_context_policy = None;
369 effective
370}
371
372impl Compiler {
373 pub fn new(registry: SpawnerRegistry) -> Self {
377 Self {
378 registry,
379 default_spawner: None,
380 }
381 }
382
383 pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
387 self.default_spawner = Some(sp);
388 self
389 }
390
391 pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
396 let bound_agents = resolve_bound_agents(bp)?;
397 self.compile_bound(bp, &bound_agents)
398 }
399
400 pub fn compile_bound(
405 &self,
406 bp: &Blueprint,
407 bound_agents: &[BoundAgent],
408 ) -> Result<CompiledBlueprint, CompileError> {
409 let effective = materialize_bound_blueprint(bp, bound_agents);
410 self.compile_resolved(&effective)
411 }
412
413 fn compile_resolved(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
414 let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
415 let mut seen: HashMap<String, ()> = HashMap::new();
416 let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
422
423 let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
429 for ad in &bp.agents {
430 if !matches!(ad.kind, AgentKind::Operator) {
431 continue;
432 }
433 let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
434 if let Some(op_ref) = op_ref {
435 if !defined.iter().any(|n| n == op_ref) {
436 return Err(CompileError::UnresolvedOperatorRef {
437 agent: ad.name.clone(),
438 op_ref: op_ref.to_string(),
439 defined: defined.clone(),
440 });
441 }
442 }
443 }
445
446 let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
450 for ad in &bp.agents {
451 let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
452 if let Some(meta_ref) = meta_ref {
453 if !metas_defined.iter().any(|n| n == meta_ref) {
454 return Err(CompileError::UnresolvedMetaRef {
455 where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
456 meta_ref: meta_ref.clone(),
457 defined: metas_defined.clone(),
458 });
459 }
460 }
461 }
462 let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
468 collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
469 for (where_, meta_ref) in static_step_meta_refs {
470 if !metas_defined.iter().any(|n| n == &meta_ref) {
471 return Err(CompileError::UnresolvedMetaRef {
472 where_,
473 meta_ref,
474 defined: metas_defined.clone(),
475 });
476 }
477 }
478
479 let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
484 for audit in &bp.audits {
485 if !agents_defined.iter().any(|n| n == &audit.agent) {
486 return Err(CompileError::UnresolvedAuditAgent {
487 agent: audit.agent.clone(),
488 defined: agents_defined.clone(),
489 });
490 }
491 }
492
493 for ad in &bp.agents {
494 if seen.contains_key(&ad.name) {
495 return Err(CompileError::DuplicateAgent(ad.name.clone()));
496 }
497 seen.insert(ad.name.clone(), ());
498
499 if let Some(contract) = &ad.verdict {
505 verdict_contracts.insert(ad.name.clone(), contract.clone());
506 }
507
508 let factory = match self.registry.factories.get(&ad.kind) {
509 Some(f) => f.clone(),
510 None => {
511 if bp.strategy.strict_kind {
512 return Err(CompileError::UnknownKind(ad.kind.clone()));
513 } else {
514 tracing::warn!(
515 agent = %ad.name,
516 kind = ?ad.kind,
517 "no spawner factory registered for agent kind; \
518 dropping agent from routing table (strict_kind=false)"
519 );
520 continue;
521 }
522 }
523 };
524 let hint = bp.hints.per_agent.get(&ad.name);
525 let spawner = factory.build(ad, hint)?;
526 routes.insert(ad.name.clone(), spawner);
527 }
528
529 let strict_verdict_handling = bp.metadata.strict_verdict_handling.unwrap_or(false);
546 verify_verdict_conds(&bp.flow, &verdict_contracts, strict_verdict_handling)?;
547
548 if bp.strategy.strict_refs {
549 verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
550 }
551
552 let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
560 for warning in &step_naming_warnings {
561 tracing::warn!(
562 name = %warning.name,
563 first_step_ref = %warning.first_step_ref,
564 second_step_ref = %warning.second_step_ref,
565 "StepNaming: undeclared steps' canonical/alias names collide; \
566 the step whose own ref matches the name keeps it (data-plane priority)"
567 );
568 }
569
570 let projection_placement =
578 ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
579
580 let router = Arc::new(CompiledAgentTable {
581 routes,
582 default: self.default_spawner.clone(),
583 verdict_contracts,
584 });
585 Ok(CompiledBlueprint {
586 router,
587 flow: bp.flow.clone(),
588 metadata: bp.metadata.clone(),
589 step_naming: Arc::new(step_naming),
590 projection_placement: Arc::new(projection_placement),
591 })
592 }
593}
594
595fn verify_refs(
598 node: &FlowNode,
599 routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
600 has_default: bool,
601) -> Result<(), CompileError> {
602 let mut refs: Vec<String> = Vec::new();
603 collect_refs(node, &mut refs);
604 for r in refs {
605 if !routes.contains_key(&r) && !has_default {
606 return Err(CompileError::UnresolvedRef(r));
607 }
608 }
609 Ok(())
610}
611
612fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
613 match node {
614 FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
615 FlowNode::Seq { children } => {
616 for c in children {
617 collect_refs(c, out);
618 }
619 }
620 FlowNode::Branch { then_, else_, .. } => {
621 collect_refs(then_, out);
622 collect_refs(else_, out);
623 }
624 FlowNode::Fanout { body, .. } => collect_refs(body, out),
625 FlowNode::Loop { body, .. } => collect_refs(body, out),
626 FlowNode::Try { body, catch, .. } => {
627 collect_refs(body, out);
628 collect_refs(catch, out);
629 }
630 FlowNode::Assign { .. } => {} }
632}
633
634fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
642 match node {
643 FlowNode::Step { ref_, in_, .. } => {
644 if let Expr::Lit { value } = in_ {
645 if let Some(meta_ref) = static_step_meta_ref(value) {
646 out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
647 }
648 }
649 }
650 FlowNode::Seq { children } => {
651 for c in children {
652 collect_step_meta_refs(c, out);
653 }
654 }
655 FlowNode::Branch { then_, else_, .. } => {
656 collect_step_meta_refs(then_, out);
657 collect_step_meta_refs(else_, out);
658 }
659 FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
660 FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
661 FlowNode::Try { body, catch, .. } => {
662 collect_step_meta_refs(body, out);
663 collect_step_meta_refs(catch, out);
664 }
665 FlowNode::Assign { .. } => {} }
667}
668
669fn static_step_meta_ref(value: &Value) -> Option<String> {
676 value
677 .as_object()?
678 .get("$step_meta")?
679 .as_object()?
680 .get("ref")?
681 .as_str()
682 .map(str::to_string)
683}
684
685fn verify_verdict_conds(
697 flow: &FlowNode,
698 verdict_contracts: &HashMap<String, VerdictContract>,
699 strict_verdict_handling: bool,
700) -> Result<(), CompileError> {
701 let mut step_outputs: HashMap<String, String> = HashMap::new();
702 let mut step_agents: HashMap<String, String> = HashMap::new();
703 collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
704
705 let mut errors: Vec<CompileError> = Vec::new();
706 let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
707 collect_verdict_conds(
708 flow,
709 &step_outputs,
710 verdict_contracts,
711 &mut referenced_values,
712 &mut errors,
713 );
714 check_unhandled_verdict_values(
715 verdict_contracts,
716 &referenced_values,
717 &step_agents,
718 strict_verdict_handling,
719 &mut errors,
720 );
721 match errors.into_iter().next() {
722 Some(e) => Err(e),
723 None => Ok(()),
724 }
725}
726
727fn collect_step_outputs_and_agents(
742 node: &FlowNode,
743 out: &mut HashMap<String, String>,
744 step_agents: &mut HashMap<String, String>,
745) {
746 match node {
747 FlowNode::Step {
748 ref_,
749 out: out_expr,
750 ..
751 } => {
752 if let Expr::Path { at } = out_expr {
753 out.insert(at.to_string(), ref_.clone());
754 }
755 step_agents
756 .entry(ref_.clone())
757 .or_insert_with(|| ref_.clone());
758 }
759 FlowNode::Seq { children } => {
760 for c in children {
761 collect_step_outputs_and_agents(c, out, step_agents);
762 }
763 }
764 FlowNode::Branch { then_, else_, .. } => {
765 collect_step_outputs_and_agents(then_, out, step_agents);
766 collect_step_outputs_and_agents(else_, out, step_agents);
767 }
768 FlowNode::Fanout { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
769 FlowNode::Loop { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
770 FlowNode::Try { body, catch, .. } => {
771 collect_step_outputs_and_agents(body, out, step_agents);
772 collect_step_outputs_and_agents(catch, out, step_agents);
773 }
774 FlowNode::Assign { .. } => {} }
776}
777
778fn collect_verdict_conds(
783 node: &FlowNode,
784 step_outputs: &HashMap<String, String>,
785 verdict_contracts: &HashMap<String, VerdictContract>,
786 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
787 errors: &mut Vec<CompileError>,
788) {
789 match node {
790 FlowNode::Branch { cond, then_, else_ } => {
791 lint_cond_expr(
792 cond,
793 "Branch cond",
794 step_outputs,
795 verdict_contracts,
796 referenced_values,
797 errors,
798 );
799 collect_verdict_conds(
800 then_,
801 step_outputs,
802 verdict_contracts,
803 referenced_values,
804 errors,
805 );
806 collect_verdict_conds(
807 else_,
808 step_outputs,
809 verdict_contracts,
810 referenced_values,
811 errors,
812 );
813 }
814 FlowNode::Loop { cond, body, .. } => {
815 lint_cond_expr(
816 cond,
817 "Loop cond",
818 step_outputs,
819 verdict_contracts,
820 referenced_values,
821 errors,
822 );
823 collect_verdict_conds(
824 body,
825 step_outputs,
826 verdict_contracts,
827 referenced_values,
828 errors,
829 );
830 }
831 FlowNode::Seq { children } => {
832 for c in children {
833 collect_verdict_conds(
834 c,
835 step_outputs,
836 verdict_contracts,
837 referenced_values,
838 errors,
839 );
840 }
841 }
842 FlowNode::Fanout { body, .. } => collect_verdict_conds(
843 body,
844 step_outputs,
845 verdict_contracts,
846 referenced_values,
847 errors,
848 ),
849 FlowNode::Try { body, catch, .. } => {
850 collect_verdict_conds(
851 body,
852 step_outputs,
853 verdict_contracts,
854 referenced_values,
855 errors,
856 );
857 collect_verdict_conds(
858 catch,
859 step_outputs,
860 verdict_contracts,
861 referenced_values,
862 errors,
863 );
864 }
865 FlowNode::Step { .. } | FlowNode::Assign { .. } => {}
866 }
867}
868
869fn lint_cond_expr(
878 expr: &Expr,
879 where_: &str,
880 step_outputs: &HashMap<String, String>,
881 verdict_contracts: &HashMap<String, VerdictContract>,
882 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
883 errors: &mut Vec<CompileError>,
884) {
885 match expr {
886 Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
887 if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
888 resolve_and_check(
889 path,
890 &[lit],
891 where_,
892 step_outputs,
893 verdict_contracts,
894 referenced_values,
895 errors,
896 );
897 }
898 }
899 Expr::In { needle, haystack } => {
900 if let (
901 Expr::Path { at },
902 Expr::Lit {
903 value: Value::Array(items),
904 },
905 ) = (needle.as_ref(), haystack.as_ref())
906 {
907 let lits: Vec<&Value> = items.iter().collect();
908 resolve_and_check(
909 at,
910 &lits,
911 where_,
912 step_outputs,
913 verdict_contracts,
914 referenced_values,
915 errors,
916 );
917 }
918 }
919 Expr::And { args } | Expr::Or { args } => {
920 for a in args {
921 lint_cond_expr(
922 a,
923 where_,
924 step_outputs,
925 verdict_contracts,
926 referenced_values,
927 errors,
928 );
929 }
930 }
931 Expr::Not { arg } => lint_cond_expr(
932 arg,
933 where_,
934 step_outputs,
935 verdict_contracts,
936 referenced_values,
937 errors,
938 ),
939 _ => {}
940 }
941}
942
943fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
949 match (lhs, rhs) {
950 (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
951 (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
952 _ => None,
953 }
954}
955
956fn resolve_and_check(
971 path: &Path,
972 lits: &[&Value],
973 where_: &str,
974 step_outputs: &HashMap<String, String>,
975 verdict_contracts: &HashMap<String, VerdictContract>,
976 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
977 errors: &mut Vec<CompileError>,
978) {
979 let path_str = path.to_string();
980 let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
981 (agent, "body")
982 } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
983 match step_outputs.get(stripped) {
984 Some(agent) => (agent, "part"),
985 None => return,
986 }
987 } else {
988 return;
989 };
990
991 let Some(contract) = verdict_contracts.get(agent) else {
992 tracing::warn!(
993 agent = %agent,
994 where_ = %where_,
995 "cond references agent output but no verdict contract declared"
996 );
997 return;
998 };
999
1000 let expected_channel = match contract.channel {
1001 VerdictChannel::Body => "body",
1002 VerdictChannel::Part => "part",
1003 };
1004 if expected_channel != actual_shape {
1005 errors.push(CompileError::VerdictChannelMismatch {
1006 where_: where_.to_string(),
1007 agent: agent.clone(),
1008 expected_channel: expected_channel.to_string(),
1009 actual_shape: actual_shape.to_string(),
1010 });
1011 return;
1012 }
1013
1014 for lit in lits {
1015 let value_str = lit
1016 .as_str()
1017 .map(str::to_string)
1018 .unwrap_or_else(|| lit.to_string());
1019 if !contract.values.iter().any(|v| v == &value_str) {
1020 errors.push(CompileError::VerdictValueNotInContract {
1021 where_: where_.to_string(),
1022 agent: agent.clone(),
1023 value: value_str.clone(),
1024 values: contract.values.clone(),
1025 });
1026 }
1027 referenced_values
1034 .entry(agent.clone())
1035 .or_default()
1036 .insert(value_str);
1037 }
1038}
1039
1040fn check_unhandled_verdict_values(
1058 verdict_contracts: &HashMap<String, VerdictContract>,
1059 referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1060 step_agents: &HashMap<String, String>,
1061 strict_verdict_handling: bool,
1062 errors: &mut Vec<CompileError>,
1063) {
1064 let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1070 agents.sort();
1071 for agent in agents {
1072 let contract = &verdict_contracts[agent];
1073 let referenced = referenced_values.get(agent);
1074 let step_ref = step_agents
1075 .get(agent)
1076 .cloned()
1077 .unwrap_or_else(|| agent.clone());
1078 for value in &contract.values {
1079 let handled = referenced.map(|set| set.contains(value)).unwrap_or(false);
1080 if handled {
1081 continue;
1082 }
1083 if strict_verdict_handling {
1084 errors.push(CompileError::VerdictValueUnhandled {
1085 agent: agent.clone(),
1086 value: value.clone(),
1087 declared_values: contract.values.clone(),
1088 step_ref: step_ref.clone(),
1089 });
1090 } else {
1091 tracing::warn!(
1092 agent = %agent,
1093 value = %value,
1094 step_ref = %step_ref,
1095 "declared verdict value has no downstream cond handler; \
1096 opt in to `metadata.strict_verdict_handling` to reject at compile"
1097 );
1098 }
1099 }
1100 }
1101}
1102
1103pub struct CompiledAgentTable {
1116 pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
1117 pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
1118 pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
1122}
1123
1124impl CompiledAgentTable {
1125 pub fn has_route(&self, agent: &str) -> bool {
1128 self.routes.contains_key(agent)
1129 }
1130 pub fn routed_agents(&self) -> Vec<String> {
1132 self.routes.keys().cloned().collect()
1133 }
1134 pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
1138 self.verdict_contracts.get(agent)
1139 }
1140}
1141
1142#[async_trait]
1143impl SpawnerAdapter for CompiledAgentTable {
1144 async fn spawn(
1145 &self,
1146 engine: &Engine,
1147 ctx: &Ctx,
1148 task_id: StepId,
1149 attempt: u32,
1150 token: CapToken,
1151 ) -> Result<Box<dyn Worker>, SpawnError> {
1152 let sp = self
1153 .routes
1154 .get(&ctx.agent)
1155 .cloned()
1156 .or_else(|| self.default.clone())
1157 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
1158 sp.spawn(engine, ctx, task_id, attempt, token).await
1159 }
1160}
1161
1162pub struct SubprocessProcessSpawnerFactory;
1180
1181impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
1182 const KIND: AgentKind = AgentKind::Subprocess;
1183 type Worker = crate::worker::process_spawner::ProcessWorker;
1184}
1185
1186impl SpawnerFactory for SubprocessProcessSpawnerFactory {
1187 fn build(
1188 &self,
1189 agent_def: &AgentDef,
1190 _hint: Option<&Value>,
1191 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1192 let agent_name = &agent_def.name;
1193 let spec = &agent_def.spec;
1194 let invalid = |msg: String| CompileError::InvalidSpec {
1195 name: agent_name.to_string(),
1196 msg,
1197 };
1198 let program = spec
1199 .get("program")
1200 .and_then(|v| v.as_str())
1201 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
1202 .to_string();
1203 let args: Vec<String> = spec
1204 .get("args")
1205 .and_then(|v| v.as_array())
1206 .map(|a| {
1207 a.iter()
1208 .filter_map(|x| x.as_str().map(|s| s.to_string()))
1209 .collect()
1210 })
1211 .unwrap_or_default();
1212 let use_stdin = spec
1213 .get("use_stdin")
1214 .and_then(|v| v.as_bool())
1215 .unwrap_or(true);
1216 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
1217 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
1218 Some("sse_events") => Some(StreamMode::SseEvents),
1219 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
1220 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
1221 None => None,
1222 };
1223
1224 let mut sp = ProcessSpawner {
1225 program,
1226 args,
1227 use_stdin,
1228 stream_mode,
1229 };
1230 if let Some(mode) = sp.stream_mode.clone() {
1231 sp = sp.stream_mode(mode);
1232 }
1233 Ok(Arc::new(sp))
1234 }
1235}
1236
1237pub struct LuaInProcessSpawnerFactory {
1268 registry: HashMap<String, WorkerFn>,
1269 bridges: HashMap<String, HostBridge>,
1270}
1271
1272#[derive(Clone)]
1284pub struct HostBridge(
1285 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
1286);
1287
1288impl HostBridge {
1289 pub fn new<F>(f: F) -> Self
1291 where
1292 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
1293 {
1294 Self(Arc::new(f))
1295 }
1296
1297 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
1301 (self.0)(arg)
1302 }
1303}
1304
1305#[derive(Clone)]
1312pub struct LuaScriptSource {
1313 pub source: String,
1315 pub label: String,
1318}
1319
1320impl LuaScriptSource {
1321 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
1323 Self {
1324 source: source.into(),
1325 label: label.into(),
1326 }
1327 }
1328}
1329
1330impl LuaInProcessSpawnerFactory {
1331 pub fn new() -> Self {
1333 Self {
1334 registry: HashMap::new(),
1335 bridges: HashMap::new(),
1336 }
1337 }
1338
1339 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
1346 self.bridges.insert(name.into(), bridge);
1347 self
1348 }
1349
1350 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
1368 let source = Arc::new(source);
1369 let bridges = Arc::new(self.bridges.clone());
1370 let wrapped: WorkerFn = Arc::new(move |inv| {
1371 let source = source.clone();
1372 let bridges = bridges.clone();
1373 Box::pin(run_lua_worker(source, bridges, inv))
1374 });
1375 self.registry.insert(fn_id.into(), wrapped);
1376 self
1377 }
1378}
1379
1380async fn run_lua_worker(
1382 source: Arc<LuaScriptSource>,
1383 bridges: Arc<HashMap<String, HostBridge>>,
1384 inv: crate::worker::adapter::WorkerInvocation,
1385) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
1386 use crate::worker::adapter::WorkerError;
1387 use mlua::LuaSerdeExt;
1388
1389 let label = source.label.clone();
1390 let outcome =
1391 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
1392 let lua = mlua::Lua::new();
1393 let g = lua.globals();
1394
1395 g.set("_PROMPT", inv.prompt.clone())
1397 .map_err(|e| format!("set _PROMPT: {e}"))?;
1398 g.set("_AGENT", inv.agent.clone())
1399 .map_err(|e| format!("set _AGENT: {e}"))?;
1400 g.set("_TASK_ID", inv.task_id.to_string())
1401 .map_err(|e| format!("set _TASK_ID: {e}"))?;
1402 g.set("_ATTEMPT", inv.attempt as i64)
1403 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
1404
1405 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
1407 let lua_val = lua
1408 .to_value(&json_val)
1409 .map_err(|e| format!("_CTX to_value: {e}"))?;
1410 g.set("_CTX", lua_val)
1411 .map_err(|e| format!("set _CTX: {e}"))?;
1412 }
1413
1414 if !bridges.is_empty() {
1416 let host = lua
1417 .create_table()
1418 .map_err(|e| format!("create host table: {e}"))?;
1419 for (name, bridge) in bridges.iter() {
1420 let bridge = bridge.clone();
1421 let bname = name.clone();
1422 let f = lua
1423 .create_function(move |lua, arg: mlua::Value| {
1424 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
1425 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
1426 })?;
1427 let result_json =
1428 bridge.call(json_arg).map_err(mlua::Error::external)?;
1429 lua.to_value(&result_json).map_err(|e| {
1430 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
1431 })
1432 })
1433 .map_err(|e| format!("create_function {name}: {e}"))?;
1434 host.set(name.as_str(), f)
1435 .map_err(|e| format!("host.{name} set: {e}"))?;
1436 }
1437 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
1438 }
1439
1440 let result: mlua::Value = lua
1442 .load(&source.source)
1443 .set_name(&source.label)
1444 .eval()
1445 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
1446
1447 let json_result: serde_json::Value = lua
1449 .from_value(result)
1450 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
1451
1452 let (value, ok) = match &json_result {
1453 serde_json::Value::Object(map)
1454 if map.contains_key("value") || map.contains_key("ok") =>
1455 {
1456 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
1457 let value = map.get("value").cloned().unwrap_or(json_result.clone());
1458 (value, ok)
1459 }
1460 _ => (json_result, true),
1461 };
1462 Ok((value, ok))
1463 })
1464 .await
1465 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
1466 .map_err(WorkerError::Failed)?;
1467
1468 Ok(crate::worker::adapter::WorkerResult {
1469 value: outcome.0,
1470 ok: outcome.1,
1471 })
1472}
1473
1474impl Default for LuaInProcessSpawnerFactory {
1475 fn default() -> Self {
1476 Self::new()
1477 }
1478}
1479
1480impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
1481 const KIND: AgentKind = AgentKind::Lua;
1482 type Worker = LuaWorker;
1483}
1484
1485impl SpawnerFactory for LuaInProcessSpawnerFactory {
1486 fn build(
1487 &self,
1488 agent_def: &AgentDef,
1489 _hint: Option<&Value>,
1490 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1491 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
1497 let label = agent_def
1498 .spec
1499 .get("label")
1500 .and_then(|v| v.as_str())
1501 .map(str::to_string)
1502 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
1503 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
1504 let bridges = Arc::new(self.bridges.clone());
1505 let wrapped: WorkerFn = Arc::new(move |inv| {
1506 let source = script.clone();
1507 let bridges = bridges.clone();
1508 Box::pin(run_lua_worker(source, bridges, inv))
1509 });
1510 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
1511 sp.registry.insert(agent_def.name.to_string(), wrapped);
1512 return Ok(Arc::new(sp));
1513 }
1514 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
1515 }
1516}
1517
1518pub struct RustFnInProcessSpawnerFactory {
1532 registry: HashMap<String, WorkerFn>,
1533}
1534
1535impl RustFnInProcessSpawnerFactory {
1536 pub fn new() -> Self {
1538 Self {
1539 registry: HashMap::new(),
1540 }
1541 }
1542
1543 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
1546 where
1547 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
1548 Fut: std::future::Future<
1549 Output = Result<
1550 crate::worker::adapter::WorkerResult,
1551 crate::worker::adapter::WorkerError,
1552 >,
1553 > + Send
1554 + 'static,
1555 {
1556 let f = Arc::new(f);
1557 let wrapped: WorkerFn = Arc::new(move |inv| {
1558 let f = f.clone();
1559 Box::pin(f(inv))
1560 });
1561 self.registry.insert(fn_id.into(), wrapped);
1562 self
1563 }
1564}
1565
1566impl Default for RustFnInProcessSpawnerFactory {
1567 fn default() -> Self {
1568 Self::new()
1569 }
1570}
1571
1572impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
1573 const KIND: AgentKind = AgentKind::RustFn;
1574 type Worker = RustFnWorker;
1575}
1576
1577impl SpawnerFactory for RustFnInProcessSpawnerFactory {
1578 fn build(
1579 &self,
1580 agent_def: &AgentDef,
1581 _hint: Option<&Value>,
1582 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1583 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
1584 }
1585}
1586
1587fn build_inproc_from_registry<W>(
1593 registry: &HashMap<String, WorkerFn>,
1594 agent_def: &AgentDef,
1595 kind_label: &str,
1596) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
1597where
1598 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
1599{
1600 let agent_name = &agent_def.name;
1601 let spec = &agent_def.spec;
1602 let invalid = |msg: String| CompileError::InvalidSpec {
1603 name: agent_name.to_string(),
1604 msg,
1605 };
1606 let fn_id = spec
1607 .get("fn_id")
1608 .and_then(|v| v.as_str())
1609 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
1610 let f = registry
1611 .get(fn_id)
1612 .cloned()
1613 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
1614 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
1615 sp.registry.insert(agent_name.to_string(), f);
1619 Ok(Arc::new(sp))
1620}
1621
1622pub struct LuaWorker {
1627 pub handler: crate::worker::WorkerJoinHandler,
1629}
1630
1631impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
1632 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1633 Self { handler }
1634 }
1635}
1636
1637#[async_trait::async_trait]
1638impl crate::worker::Worker for LuaWorker {
1639 fn id(&self) -> &crate::types::WorkerId {
1640 &self.handler.worker_id
1641 }
1642 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1643 self.handler.cancel.clone()
1644 }
1645 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1646 self.handler.await_completion().await
1647 }
1648}
1649
1650pub struct RustFnWorker {
1655 pub handler: crate::worker::WorkerJoinHandler,
1657}
1658
1659impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
1660 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1661 Self { handler }
1662 }
1663}
1664
1665#[async_trait::async_trait]
1666impl crate::worker::Worker for RustFnWorker {
1667 fn id(&self) -> &crate::types::WorkerId {
1668 &self.handler.worker_id
1669 }
1670 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1671 self.handler.cancel.clone()
1672 }
1673 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1674 self.handler.await_completion().await
1675 }
1676}
1677
1678pub struct OperatorSpawnerFactory {
1731 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
1732}
1733
1734impl OperatorSpawnerFactory {
1735 pub fn new() -> Self {
1737 Self {
1738 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
1739 }
1740 }
1741
1742 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
1748 self.operators
1749 .write()
1750 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1751 .insert(id.into(), op);
1752 self
1753 }
1754
1755 pub fn unregister_operator(&self, id: &str) -> &Self {
1758 self.operators
1759 .write()
1760 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1761 .remove(id);
1762 self
1763 }
1764}
1765
1766impl Default for OperatorSpawnerFactory {
1767 fn default() -> Self {
1768 Self::new()
1769 }
1770}
1771
1772impl SpawnerFactoryKind for OperatorSpawnerFactory {
1773 const KIND: AgentKind = AgentKind::Operator;
1774 type Worker = crate::operator::OperatorWorker;
1775}
1776
1777impl SpawnerFactory for OperatorSpawnerFactory {
1778 fn build(
1779 &self,
1780 agent_def: &AgentDef,
1781 _hint: Option<&Value>,
1782 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1783 let agent_name = &agent_def.name;
1784 let spec = &agent_def.spec;
1785 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
1791 let invalid = |msg: String| CompileError::InvalidSpec {
1792 name: agent_name.to_string(),
1793 msg,
1794 };
1795 let op_ref = spec
1796 .get("operator_ref")
1797 .and_then(|v| v.as_str())
1798 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
1799 let operators = self
1800 .operators
1801 .read()
1802 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
1803 let op = operators.get(op_ref).cloned().ok_or_else(|| {
1804 let mut names: Vec<String> = operators.keys().cloned().collect();
1805 names.sort();
1806 let names_list = if names.is_empty() {
1807 "<none>".to_string()
1808 } else {
1809 names.join(", ")
1810 };
1811 invalid(format!(
1812 "operator_ref '{op_ref}' not registered in factory. \
1813 Registered sids: [{names_list}]. \
1814 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
1815 ))
1816 })?;
1817 drop(operators);
1818
1819 let worker_binding = agent_def
1826 .profile
1827 .as_ref()
1828 .and_then(|p| p.worker_binding.as_ref())
1829 .map(|variant| WorkerBinding {
1830 variant: variant.clone(),
1831 tools: agent_def
1832 .profile
1833 .as_ref()
1834 .map(|p| p.tools.clone())
1835 .unwrap_or_default(),
1836 request_digest: None,
1840 requested_model: None,
1841 });
1842 if op.requires_worker_binding() && worker_binding.is_none() {
1843 return Err(invalid(
1848 "profile.worker_binding is required for this operator backend. \
1849 Fix by either: \
1850 (a) if authoring the Blueprint JSON directly, add \
1851 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
1852 to the JSON literal; or \
1853 (b) if using an $agent_md file ref, add \
1854 `worker_binding: <subagent-type>` to the agent .md frontmatter."
1855 .into(),
1856 ));
1857 }
1858 Ok(Arc::new(OperatorSpawner::new(
1859 op,
1860 system_prompt,
1861 worker_binding,
1862 )))
1863 }
1864}
1865
1866#[cfg(test)]
1867mod operator_spawner_factory_worker_binding_tests {
1868 use super::*;
1869 use crate::blueprint::AgentProfile;
1870 use crate::core::ctx::Ctx;
1871 use crate::types::CapToken;
1872 use crate::worker::adapter::{WorkerError, WorkerResult};
1873
1874 struct StubOperator {
1879 requires_binding: bool,
1880 }
1881
1882 #[async_trait]
1883 impl Operator for StubOperator {
1884 async fn execute(
1885 &self,
1886 _ctx: &Ctx,
1887 _system: Option<String>,
1888 _prompt: Value,
1889 _worker: Option<WorkerBinding>,
1890 _worker_token: CapToken,
1891 ) -> Result<WorkerResult, WorkerError> {
1892 Ok(WorkerResult {
1893 value: Value::Null,
1894 ok: true,
1895 })
1896 }
1897
1898 fn requires_worker_binding(&self) -> bool {
1899 self.requires_binding
1900 }
1901 }
1902
1903 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
1904 AgentDef {
1905 name: "test-agent".to_string(),
1906 kind: AgentKind::Operator,
1907 spec: serde_json::json!({ "operator_ref": "op1" }),
1908 profile,
1909 meta: None,
1910 runner: None,
1911 runner_ref: None,
1912 verdict: None,
1913 }
1914 }
1915
1916 #[test]
1917 fn build_fails_loud_when_binding_required_but_absent() {
1918 let factory = OperatorSpawnerFactory::new();
1919 factory.register_operator(
1920 "op1",
1921 Arc::new(StubOperator {
1922 requires_binding: true,
1923 }) as Arc<dyn Operator>,
1924 );
1925 let def = agent_def_with(Some(AgentProfile::default()));
1926 match factory.build(&def, None) {
1927 Err(CompileError::InvalidSpec { name, msg }) => {
1928 assert_eq!(name, "test-agent");
1929 assert!(
1930 msg.contains("worker_binding is required"),
1931 "unexpected message: {msg}"
1932 );
1933 assert!(
1937 msg.contains("agents[N].profile.worker_binding"),
1938 "message missing JSON-direct hint (issue #9): {msg}"
1939 );
1940 assert!(
1941 msg.contains("agent .md frontmatter"),
1942 "message missing $agent_md hint: {msg}"
1943 );
1944 }
1945 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
1946 Ok(_) => panic!("expected compile-time failure, got Ok"),
1947 }
1948 }
1949
1950 #[test]
1951 fn build_succeeds_when_binding_required_and_present() {
1952 let factory = OperatorSpawnerFactory::new();
1953 factory.register_operator(
1954 "op1",
1955 Arc::new(StubOperator {
1956 requires_binding: true,
1957 }) as Arc<dyn Operator>,
1958 );
1959 let profile = AgentProfile {
1960 worker_binding: Some("mse-worker-coder".to_string()),
1961 tools: vec!["Read".to_string(), "Edit".to_string()],
1962 ..Default::default()
1963 };
1964 let def = agent_def_with(Some(profile));
1965 assert!(
1966 factory.build(&def, None).is_ok(),
1967 "expected Ok when worker_binding is declared"
1968 );
1969 }
1970
1971 #[test]
1972 fn build_succeeds_when_binding_not_required_and_absent() {
1973 let factory = OperatorSpawnerFactory::new();
1974 factory.register_operator(
1975 "op1",
1976 Arc::new(StubOperator {
1977 requires_binding: false,
1978 }) as Arc<dyn Operator>,
1979 );
1980 let def = agent_def_with(Some(AgentProfile::default()));
1981 assert!(
1982 factory.build(&def, None).is_ok(),
1983 "backends that don't require a binding must not be gated by its absence"
1984 );
1985 }
1986}
1987
1988#[cfg(test)]
1996mod lua_inline_source_tests {
1997 use super::*;
1998 use crate::types::{CapToken, Role, StepId};
1999
2000 fn agent(name: &str, spec: Value) -> AgentDef {
2001 AgentDef {
2002 name: name.to_string(),
2003 kind: AgentKind::Lua,
2004 spec,
2005 profile: None,
2006 meta: None,
2007 runner: None,
2008 runner_ref: None,
2009 verdict: None,
2010 }
2011 }
2012
2013 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
2014 crate::worker::adapter::WorkerInvocation {
2015 token: CapToken {
2016 agent_id: "a".into(),
2017 role: Role::Worker,
2018 scopes: vec!["*".into()],
2019 issued_at: 0,
2020 expire_at: u64::MAX / 2,
2021 max_uses: None,
2022 nonce: "test-nonce".into(),
2023 sig_hex: "".into(),
2024 },
2025 task_id: StepId::parse("ST-test").expect("StepId parse"),
2026 attempt: 1,
2027 agent: "g".into(),
2028 prompt: prompt.into(),
2029 sink: None,
2030 cancel_token: None,
2031 }
2032 }
2033
2034 #[test]
2035 fn build_accepts_inline_source_without_pre_registration() {
2036 let factory = LuaInProcessSpawnerFactory::new();
2037 let def = agent(
2038 "g",
2039 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
2040 );
2041 assert!(
2042 factory.build(&def, None).is_ok(),
2043 "inline spec.source must build without a pre-registered fn_id"
2044 );
2045 }
2046
2047 #[test]
2048 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
2049 let factory = LuaInProcessSpawnerFactory::new();
2050 let def = agent("g", serde_json::json!({}));
2051 match factory.build(&def, None) {
2052 Err(CompileError::InvalidSpec { msg, .. }) => {
2053 assert!(
2054 msg.contains("fn_id"),
2055 "empty spec must still surface the fn_id-required message: {msg}"
2056 );
2057 }
2058 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
2059 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
2062 }
2063 }
2064
2065 #[tokio::test]
2069 async fn inline_source_evaluates_and_marshals_result() {
2070 let source =
2071 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
2072 let out = run_lua_worker(
2073 std::sync::Arc::new(source),
2074 std::sync::Arc::new(HashMap::new()),
2075 test_invocation("hello"),
2076 )
2077 .await
2078 .expect("lua worker ok");
2079 assert_eq!(out.value, serde_json::json!("hello!"));
2080 assert!(out.ok);
2081 }
2082
2083 #[tokio::test]
2084 async fn inline_source_can_signal_agent_level_failure() {
2085 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
2088 let out = run_lua_worker(
2089 std::sync::Arc::new(source),
2090 std::sync::Arc::new(HashMap::new()),
2091 test_invocation("input"),
2092 )
2093 .await
2094 .expect("lua worker ok");
2095 assert_eq!(out.value, serde_json::json!("nope"));
2096 assert!(!out.ok);
2097 }
2098}
2099
2100#[cfg(test)]
2103mod meta_ref_validation_tests {
2104 use super::*;
2105 use crate::blueprint::{AgentMeta, MetaDef};
2106 use crate::worker::adapter::WorkerResult;
2107
2108 fn registry_with_echo() -> SpawnerRegistry {
2109 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2110 Ok(WorkerResult {
2111 value: Value::String(inv.prompt),
2112 ok: true,
2113 })
2114 });
2115 let mut reg = SpawnerRegistry::new();
2116 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2117 reg
2118 }
2119
2120 fn rustfn_agent(name: &str) -> AgentDef {
2121 AgentDef {
2122 name: name.to_string(),
2123 kind: AgentKind::RustFn,
2124 spec: serde_json::json!({ "fn_id": "echo" }),
2125 profile: None,
2126 meta: None,
2127 runner: None,
2128 runner_ref: None,
2129 verdict: None,
2130 }
2131 }
2132
2133 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
2134 FlowNode::Step {
2135 ref_: agent_ref.to_string(),
2136 in_,
2137 out: Expr::Path {
2138 at: "$.output".parse().expect("literal test path: $.output"),
2139 },
2140 }
2141 }
2142
2143 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
2144 Blueprint {
2145 schema_version: crate::blueprint::current_schema_version(),
2146 id: "meta-ref-ut".into(),
2147 flow,
2148 agents,
2149 operators: vec![],
2150 metas,
2151 hints: Default::default(),
2152 strategy: Default::default(),
2153 metadata: BlueprintMetadata::default(),
2154 spawner_hints: Default::default(),
2155 default_agent_kind: AgentKind::Operator,
2156 default_operator_kind: None,
2157 default_init_ctx: None,
2158 default_agent_ctx: None,
2159 default_context_policy: None,
2160 projection_placement: None,
2161 audits: vec![],
2162 degradation_policy: None,
2163 runners: vec![],
2164 default_runner: None,
2165 check_policy: None,
2166 blueprint_ref_includes: Vec::new(),
2167 }
2168 }
2169
2170 #[test]
2171 fn valid_meta_ref_compiles() {
2172 let mut agent = rustfn_agent("worker");
2173 agent.meta = Some(AgentMeta {
2174 meta_ref: Some("shared".to_string()),
2175 ..Default::default()
2176 });
2177 let bp = minimal_bp(
2178 vec![agent],
2179 vec![MetaDef {
2180 name: "shared".into(),
2181 ctx: serde_json::json!({ "k": "v" }),
2182 }],
2183 simple_flow(
2184 "worker",
2185 Expr::Path {
2186 at: "$.input".parse().expect("literal test path: $.input"),
2187 },
2188 ),
2189 );
2190 let compiler = Compiler::new(registry_with_echo());
2191 assert!(
2192 compiler.compile(&bp).is_ok(),
2193 "a resolvable AgentMeta.meta_ref must compile"
2194 );
2195 }
2196
2197 #[test]
2198 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
2199 let mut agent = rustfn_agent("worker");
2200 agent.meta = Some(AgentMeta {
2201 meta_ref: Some("missing".to_string()),
2202 ..Default::default()
2203 });
2204 let bp = minimal_bp(
2205 vec![agent],
2206 vec![],
2207 simple_flow(
2208 "worker",
2209 Expr::Path {
2210 at: "$.input".parse().expect("literal test path: $.input"),
2211 },
2212 ),
2213 );
2214 let compiler = Compiler::new(registry_with_echo());
2215 match compiler.compile(&bp) {
2216 Err(CompileError::UnresolvedMetaRef {
2217 where_,
2218 meta_ref,
2219 defined,
2220 }) => {
2221 assert!(
2222 where_.contains("worker"),
2223 "where_ must name the agent: {where_}"
2224 );
2225 assert_eq!(meta_ref, "missing");
2226 assert!(defined.is_empty());
2227 }
2228 Err(other) => {
2229 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
2230 }
2231 Ok(_) => panic!("expected compile-time failure, got Ok"),
2232 }
2233 }
2234
2235 #[test]
2236 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
2237 let agent = rustfn_agent("worker");
2238 let in_ = Expr::Lit {
2239 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
2240 };
2241 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
2242 let compiler = Compiler::new(registry_with_echo());
2243 match compiler.compile(&bp) {
2244 Err(CompileError::UnresolvedMetaRef {
2245 where_, meta_ref, ..
2246 }) => {
2247 assert!(
2248 where_.contains("worker"),
2249 "where_ must name the offending step: {where_}"
2250 );
2251 assert_eq!(meta_ref, "missing");
2252 }
2253 Err(other) => {
2254 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
2255 }
2256 Ok(_) => panic!("expected compile-time failure, got Ok"),
2257 }
2258 }
2259
2260 #[test]
2261 fn path_op_input_with_no_static_envelope_compiles_fine() {
2262 let agent = rustfn_agent("worker");
2263 let bp = minimal_bp(
2264 vec![agent],
2265 vec![],
2266 simple_flow(
2267 "worker",
2268 Expr::Path {
2269 at: "$.input".parse().expect("literal test path: $.input"),
2270 },
2271 ),
2272 );
2273 let compiler = Compiler::new(registry_with_echo());
2274 assert!(
2275 compiler.compile(&bp).is_ok(),
2276 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
2277 );
2278 }
2279}
2280
2281#[cfg(test)]
2283mod audit_agent_validation_tests {
2284 use super::*;
2285 use crate::worker::adapter::WorkerResult;
2286 use mlua_swarm_schema::{AuditDef, AuditMode};
2287
2288 fn registry_with_echo() -> SpawnerRegistry {
2289 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2290 Ok(WorkerResult {
2291 value: Value::String(inv.prompt),
2292 ok: true,
2293 })
2294 });
2295 let mut reg = SpawnerRegistry::new();
2296 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2297 reg
2298 }
2299
2300 fn rustfn_agent(name: &str) -> AgentDef {
2301 AgentDef {
2302 name: name.to_string(),
2303 kind: AgentKind::RustFn,
2304 spec: serde_json::json!({ "fn_id": "echo" }),
2305 profile: None,
2306 meta: None,
2307 runner: None,
2308 runner_ref: None,
2309 verdict: None,
2310 }
2311 }
2312
2313 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
2314 Blueprint {
2315 schema_version: crate::blueprint::current_schema_version(),
2316 id: "audit-ref-ut".into(),
2317 flow: FlowNode::Step {
2318 ref_: "worker".to_string(),
2319 in_: Expr::Path {
2320 at: "$.input".parse().expect("literal test path: $.input"),
2321 },
2322 out: Expr::Path {
2323 at: "$.output".parse().expect("literal test path: $.output"),
2324 },
2325 },
2326 agents,
2327 operators: vec![],
2328 metas: vec![],
2329 hints: Default::default(),
2330 strategy: Default::default(),
2331 metadata: BlueprintMetadata::default(),
2332 spawner_hints: Default::default(),
2333 default_agent_kind: AgentKind::Operator,
2334 default_operator_kind: None,
2335 default_init_ctx: None,
2336 default_agent_ctx: None,
2337 default_context_policy: None,
2338 projection_placement: None,
2339 audits,
2340 degradation_policy: None,
2341 runners: vec![],
2342 default_runner: None,
2343 check_policy: None,
2344 blueprint_ref_includes: Vec::new(),
2345 }
2346 }
2347
2348 #[test]
2349 fn unresolved_audit_agent_is_a_loud_compile_error() {
2350 let bp = minimal_bp(
2351 vec![rustfn_agent("worker")],
2352 vec![AuditDef {
2353 agent: "missing-auditor".to_string(),
2354 steps: None,
2355 mode: AuditMode::default(),
2356 }],
2357 );
2358 let compiler = Compiler::new(registry_with_echo());
2359 match compiler.compile(&bp) {
2360 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
2361 assert_eq!(agent, "missing-auditor");
2362 assert_eq!(defined, vec!["worker".to_string()]);
2363 }
2364 Err(other) => {
2365 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
2366 }
2367 Ok(_) => panic!("expected compile-time failure, got Ok"),
2368 }
2369 }
2370
2371 #[test]
2372 fn resolved_audit_agent_compiles_fine() {
2373 let bp = minimal_bp(
2374 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
2375 vec![AuditDef {
2376 agent: "auditor".to_string(),
2377 steps: None,
2378 mode: AuditMode::default(),
2379 }],
2380 );
2381 let compiler = Compiler::new(registry_with_echo());
2382 assert!(
2383 compiler.compile(&bp).is_ok(),
2384 "an audits[].agent that names a declared AgentDef must compile"
2385 );
2386 }
2387}
2388
2389#[cfg(test)]
2392mod projection_placement_compile_tests {
2393 use super::*;
2394 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
2395 use crate::worker::adapter::WorkerResult;
2396 use mlua_swarm_schema::ProjectionPlacementSpec;
2397
2398 fn registry_with_echo() -> SpawnerRegistry {
2399 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2400 Ok(WorkerResult {
2401 value: Value::String(inv.prompt),
2402 ok: true,
2403 })
2404 });
2405 let mut reg = SpawnerRegistry::new();
2406 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2407 reg
2408 }
2409
2410 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
2411 Blueprint {
2412 schema_version: crate::blueprint::current_schema_version(),
2413 id: "projection-placement-ut".into(),
2414 flow: FlowNode::Step {
2415 ref_: "worker".to_string(),
2416 in_: Expr::Path {
2417 at: "$.input".parse().expect("literal test path: $.input"),
2418 },
2419 out: Expr::Path {
2420 at: "$.output".parse().expect("literal test path: $.output"),
2421 },
2422 },
2423 agents: vec![AgentDef {
2424 name: "worker".to_string(),
2425 kind: AgentKind::RustFn,
2426 spec: serde_json::json!({ "fn_id": "echo" }),
2427 profile: None,
2428 meta: None,
2429 runner: None,
2430 runner_ref: None,
2431 verdict: None,
2432 }],
2433 operators: vec![],
2434 metas: vec![],
2435 hints: Default::default(),
2436 strategy: Default::default(),
2437 metadata: BlueprintMetadata::default(),
2438 spawner_hints: Default::default(),
2439 default_agent_kind: AgentKind::Operator,
2440 default_operator_kind: None,
2441 default_init_ctx: None,
2442 default_agent_ctx: None,
2443 default_context_policy: None,
2444 projection_placement,
2445 audits: vec![],
2446 degradation_policy: None,
2447 runners: vec![],
2448 default_runner: None,
2449 check_policy: None,
2450 blueprint_ref_includes: Vec::new(),
2451 }
2452 }
2453
2454 #[test]
2455 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
2456 let bp = minimal_bp(None);
2457 let compiled = Compiler::new(registry_with_echo())
2458 .compile(&bp)
2459 .expect("undeclared projection_placement compiles");
2460 assert_eq!(
2461 *compiled.projection_placement,
2462 ProjectionPlacement::default()
2463 );
2464 }
2465
2466 #[test]
2467 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
2468 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2469 root: Some("project_root".to_string()),
2470 dir_template: Some("custom/{task_id}/out".to_string()),
2471 }));
2472 let compiled = Compiler::new(registry_with_echo())
2473 .compile(&bp)
2474 .expect("valid projection_placement compiles");
2475 assert_eq!(
2476 compiled.projection_placement.root_preference,
2477 RootPreference::ProjectRoot
2478 );
2479 assert_eq!(
2480 compiled.projection_placement.dir_template,
2481 "custom/{task_id}/out"
2482 );
2483 }
2484
2485 #[test]
2486 fn declared_invalid_dir_template_rejects_compile() {
2487 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2488 root: None,
2489 dir_template: Some("workspace/tasks/ctx".to_string()), }));
2491 match Compiler::new(registry_with_echo()).compile(&bp) {
2492 Err(CompileError::InvalidProjectionPlacement(_)) => {}
2493 Err(other) => {
2494 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2495 }
2496 Ok(_) => {
2497 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
2498 }
2499 }
2500 }
2501
2502 #[test]
2503 fn declared_invalid_root_literal_rejects_compile() {
2504 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2505 root: Some("nope".to_string()),
2506 dir_template: None,
2507 }));
2508 match Compiler::new(registry_with_echo()).compile(&bp) {
2509 Err(CompileError::InvalidProjectionPlacement(_)) => {}
2510 Err(other) => {
2511 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2512 }
2513 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
2514 }
2515 }
2516}
2517
2518#[cfg(test)]
2520mod verdict_contract_lint_tests {
2521 use super::*;
2522 use crate::worker::adapter::WorkerResult;
2523
2524 fn registry_with_echo() -> SpawnerRegistry {
2525 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2526 Ok(WorkerResult {
2527 value: Value::String(inv.prompt),
2528 ok: true,
2529 })
2530 });
2531 let mut reg = SpawnerRegistry::new();
2532 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2533 reg
2534 }
2535
2536 fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
2537 AgentDef {
2538 name: "gate".to_string(),
2539 kind: AgentKind::RustFn,
2540 spec: serde_json::json!({ "fn_id": "echo" }),
2541 profile: None,
2542 meta: None,
2543 runner: None,
2544 runner_ref: None,
2545 verdict,
2546 }
2547 }
2548
2549 fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
2550 Blueprint {
2551 schema_version: crate::blueprint::current_schema_version(),
2552 id: "verdict-contract-ut".into(),
2553 flow,
2554 agents: vec![agent],
2555 operators: vec![],
2556 metas: vec![],
2557 hints: Default::default(),
2558 strategy: Default::default(),
2559 metadata: BlueprintMetadata::default(),
2560 spawner_hints: Default::default(),
2561 default_agent_kind: AgentKind::Operator,
2562 default_operator_kind: None,
2563 default_init_ctx: None,
2564 default_agent_ctx: None,
2565 default_context_policy: None,
2566 projection_placement: None,
2567 audits: vec![],
2568 degradation_policy: None,
2569 runners: vec![],
2570 default_runner: None,
2571 check_policy: None,
2572 blueprint_ref_includes: Vec::new(),
2573 }
2574 }
2575
2576 fn step(ref_: &str, out_path: &str) -> FlowNode {
2577 FlowNode::Step {
2578 ref_: ref_.to_string(),
2579 in_: Expr::Lit { value: Value::Null },
2580 out: Expr::Path {
2581 at: out_path.parse().expect("literal test path"),
2582 },
2583 }
2584 }
2585
2586 fn noop() -> FlowNode {
2587 FlowNode::Seq { children: vec![] }
2588 }
2589
2590 fn eq_cond(path: &str, lit: &str) -> Expr {
2591 Expr::Eq {
2592 lhs: Box::new(Expr::Path {
2593 at: path.parse().expect("literal test path"),
2594 }),
2595 rhs: Box::new(Expr::Lit {
2596 value: Value::String(lit.to_string()),
2597 }),
2598 }
2599 }
2600
2601 fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
2602 FlowNode::Branch {
2603 cond,
2604 then_: Box::new(then_),
2605 else_: Box::new(else_),
2606 }
2607 }
2608
2609 fn body_contract(values: &[&str]) -> VerdictContract {
2610 VerdictContract {
2611 channel: VerdictChannel::Body,
2612 values: values.iter().map(|v| v.to_string()).collect(),
2613 }
2614 }
2615
2616 fn part_contract(values: &[&str]) -> VerdictContract {
2617 VerdictContract {
2618 channel: VerdictChannel::Part,
2619 values: values.iter().map(|v| v.to_string()).collect(),
2620 }
2621 }
2622
2623 #[test]
2624 fn contract_with_correct_body_channel_and_value_compiles() {
2625 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2626 let flow = FlowNode::Seq {
2627 children: vec![
2628 step("gate", "$.verdict"),
2629 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2630 ],
2631 };
2632 let bp = minimal_bp(agent, flow);
2633 assert!(
2634 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2635 "a cond addressing the bare step output must match a channel: \"body\" contract"
2636 );
2637 }
2638
2639 #[test]
2640 fn contract_with_correct_part_channel_and_value_compiles() {
2641 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2642 let flow = FlowNode::Seq {
2643 children: vec![
2644 step("gate", "$.gate"),
2645 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2646 ],
2647 };
2648 let bp = minimal_bp(agent, flow);
2649 assert!(
2650 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2651 "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
2652 );
2653 }
2654
2655 #[test]
2656 fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
2657 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2661 let flow = FlowNode::Seq {
2662 children: vec![
2663 step("gate", "$.gate"),
2664 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2665 ],
2666 };
2667 let bp = minimal_bp(agent, flow);
2668 match Compiler::new(registry_with_echo()).compile(&bp) {
2669 Err(CompileError::VerdictChannelMismatch {
2670 where_,
2671 agent,
2672 expected_channel,
2673 actual_shape,
2674 }) => {
2675 assert_eq!(agent, "gate");
2676 assert_eq!(expected_channel, "body");
2677 assert_eq!(actual_shape, "part");
2678 assert!(where_.contains("Branch cond"), "where_: {where_}");
2679 }
2680 Err(other) => {
2681 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2682 }
2683 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2684 }
2685 }
2686
2687 #[test]
2688 fn part_channel_contract_rejects_cond_addressing_bare_output() {
2689 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2692 let flow = FlowNode::Seq {
2693 children: vec![
2694 step("gate", "$.verdict"),
2695 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2696 ],
2697 };
2698 let bp = minimal_bp(agent, flow);
2699 match Compiler::new(registry_with_echo()).compile(&bp) {
2700 Err(CompileError::VerdictChannelMismatch {
2701 agent,
2702 expected_channel,
2703 actual_shape,
2704 ..
2705 }) => {
2706 assert_eq!(agent, "gate");
2707 assert_eq!(expected_channel, "part");
2708 assert_eq!(actual_shape, "body");
2709 }
2710 Err(other) => {
2711 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2712 }
2713 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2714 }
2715 }
2716
2717 #[test]
2718 fn contract_rejects_lit_outside_declared_values() {
2719 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2720 let flow = FlowNode::Seq {
2721 children: vec![
2722 step("gate", "$.verdict"),
2723 branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
2724 ],
2725 };
2726 let bp = minimal_bp(agent, flow);
2727 match Compiler::new(registry_with_echo()).compile(&bp) {
2728 Err(CompileError::VerdictValueNotInContract {
2729 agent,
2730 value,
2731 values,
2732 ..
2733 }) => {
2734 assert_eq!(agent, "gate");
2735 assert_eq!(value, "UNKNOWN");
2736 assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
2737 }
2738 Err(other) => {
2739 panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
2740 }
2741 Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
2742 }
2743 }
2744
2745 #[test]
2746 fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
2747 let agent = gate_agent(None);
2748 let flow = FlowNode::Seq {
2749 children: vec![
2750 step("gate", "$.verdict"),
2751 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2752 ],
2753 };
2754 let bp = minimal_bp(agent, flow);
2755 assert!(
2756 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2757 "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
2758 );
2759 }
2760
2761 #[test]
2762 fn in_expr_with_lit_haystack_members_compiles() {
2763 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2764 let cond = Expr::In {
2765 needle: Box::new(Expr::Path {
2766 at: "$.verdict".parse().expect("literal test path"),
2767 }),
2768 haystack: Box::new(Expr::Lit {
2769 value: serde_json::json!(["PASS", "BLOCKED"]),
2770 }),
2771 };
2772 let flow = FlowNode::Seq {
2773 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
2774 };
2775 let bp = minimal_bp(agent, flow);
2776 assert!(
2777 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2778 "an `In` haystack whose every Lit is a declared value must compile"
2779 );
2780 }
2781
2782 #[test]
2789 fn strict_mode_rejects_unhandled_declared_value() {
2790 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2791 let flow = FlowNode::Seq {
2792 children: vec![
2793 step("gate", "$.verdict"),
2794 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2795 ],
2796 };
2797 let mut bp = minimal_bp(agent, flow);
2798 bp.metadata.strict_verdict_handling = Some(true);
2799 match Compiler::new(registry_with_echo()).compile(&bp) {
2800 Err(CompileError::VerdictValueUnhandled {
2801 agent,
2802 value,
2803 declared_values,
2804 step_ref,
2805 }) => {
2806 assert_eq!(agent, "gate");
2807 assert_eq!(value, "PASS");
2808 assert_eq!(
2809 declared_values,
2810 vec!["PASS".to_string(), "BLOCKED".to_string()]
2811 );
2812 assert_eq!(step_ref, "gate");
2813 }
2814 Err(other) => {
2815 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
2816 }
2817 Ok(_) => panic!(
2818 "expected compile-time rejection for a declared verdict value with no \
2819 downstream handler under strict_verdict_handling=Some(true)"
2820 ),
2821 }
2822 }
2823
2824 #[test]
2831 fn default_mode_permits_unhandled_declared_value() {
2832 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2833 let flow = FlowNode::Seq {
2834 children: vec![
2835 step("gate", "$.verdict"),
2836 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2837 ],
2838 };
2839 let bp = minimal_bp(agent, flow);
2840 assert!(
2842 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2843 "default mode must never reject a Blueprint for unhandled declared values \
2844 (opt-in, back-compat with GH #50)"
2845 );
2846 }
2847
2848 #[test]
2853 fn strict_mode_accepts_all_declared_values_handled() {
2854 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2855 let flow = FlowNode::Seq {
2858 children: vec![
2859 step("gate", "$.verdict"),
2860 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2861 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
2862 ],
2863 };
2864 let mut bp = minimal_bp(agent, flow);
2865 bp.metadata.strict_verdict_handling = Some(true);
2866 assert!(
2867 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2868 "strict mode must accept a Blueprint that handles every declared value"
2869 );
2870 }
2871
2872 #[test]
2876 fn strict_mode_accepts_declared_values_covered_by_in_expr() {
2877 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2878 let cond = Expr::In {
2879 needle: Box::new(Expr::Path {
2880 at: "$.verdict".parse().expect("literal test path"),
2881 }),
2882 haystack: Box::new(Expr::Lit {
2883 value: serde_json::json!(["PASS", "BLOCKED"]),
2884 }),
2885 };
2886 let flow = FlowNode::Seq {
2887 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
2888 };
2889 let mut bp = minimal_bp(agent, flow);
2890 bp.metadata.strict_verdict_handling = Some(true);
2891 assert!(
2892 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2893 "strict mode must accept an `In` haystack that covers every declared value"
2894 );
2895 }
2896
2897 #[test]
2901 fn strict_mode_rejects_unhandled_part_channel_value() {
2902 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2903 let flow = FlowNode::Seq {
2904 children: vec![
2905 step("gate", "$.gate"),
2906 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2907 ],
2908 };
2909 let mut bp = minimal_bp(agent, flow);
2910 bp.metadata.strict_verdict_handling = Some(true);
2911 match Compiler::new(registry_with_echo()).compile(&bp) {
2912 Err(CompileError::VerdictValueUnhandled {
2913 agent,
2914 value,
2915 step_ref,
2916 ..
2917 }) => {
2918 assert_eq!(agent, "gate");
2919 assert_eq!(value, "PASS");
2920 assert_eq!(step_ref, "gate");
2921 }
2922 Err(other) => {
2923 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
2924 }
2925 Ok(_) => panic!(
2926 "expected compile-time rejection for a declared verdict value with no \
2927 downstream handler (part channel) under strict_verdict_handling=Some(true)"
2928 ),
2929 }
2930 }
2931
2932 #[test]
2939 fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
2940 let agent = gate_agent(None);
2941 let flow = FlowNode::Seq {
2942 children: vec![
2943 step("gate", "$.verdict"),
2944 FlowNode::Loop {
2945 counter: Expr::Path {
2946 at: "$.n".parse().expect("literal test path"),
2947 },
2948 cond: eq_cond("$.verdict", "BLOCKED"),
2949 body: Box::new(step("gate", "$.verdict")),
2950 max: 3,
2951 },
2952 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
2953 ],
2954 };
2955 let bp = minimal_bp(agent, flow);
2956 let compiled = Compiler::new(registry_with_echo())
2957 .compile(&bp)
2958 .expect("a verdict-omitted Blueprint must compile unchanged");
2959 assert!(
2960 compiled.router.verdict_contracts.is_empty(),
2961 "no agent declared a verdict contract"
2962 );
2963 }
2964}