1use crate::blueprint::{AgentDef, AgentKind, Blueprint, BlueprintMetadata};
31use crate::core::ctx::Ctx;
32use crate::core::engine::Engine;
33use crate::core::projection_placement::{ProjectionPlacement, ProjectionPlacementError};
34use crate::core::step_naming::{StepNaming, StepNamingError};
35use crate::operator::{Operator, OperatorSpawner, WorkerBinding};
36use crate::types::{CapToken, StepId};
37use crate::worker::adapter::{InProcSpawner, SpawnError, SpawnerAdapter, WorkerFn};
38use crate::worker::process_spawner::{ProcessSpawner, StreamMode};
39use crate::worker::Worker;
40use async_trait::async_trait;
41use mlua_flow_ir::{Expr, Node as FlowNode, Path};
42use mlua_swarm_schema::{VerdictChannel, VerdictContract};
43use serde_json::Value;
44use std::collections::HashMap;
45use std::sync::Arc;
46use thiserror::Error;
47
48#[derive(Debug, Error)]
53pub enum CompileError {
54 #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
57 UnknownKind(AgentKind),
58 #[error("agent '{name}' spec invalid: {msg}")]
61 InvalidSpec {
62 name: String,
64 msg: String,
66 },
67 #[error("flow references agent '{0}' but no AgentDef matches")]
70 UnresolvedRef(String),
71 #[error("duplicate AgentDef name: {0}")]
73 DuplicateAgent(String),
74 #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
77 UnresolvedOperatorRef {
78 agent: String,
80 op_ref: String,
82 defined: Vec<String>,
85 },
86 #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
90 UnresolvedMetaRef {
91 where_: String,
95 meta_ref: String,
97 defined: Vec<String>,
100 },
101 #[error("StepNaming collision: {0}")]
107 StepNamingCollision(#[from] StepNamingError),
108 #[error("invalid projection_placement: {0}")]
115 InvalidProjectionPlacement(#[from] ProjectionPlacementError),
116 #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
121 UnresolvedAuditAgent {
122 agent: String,
124 defined: Vec<String>,
127 },
128 #[error(
137 "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
138 addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
139 BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
140 )]
141 VerdictChannelMismatch {
142 where_: String,
145 agent: String,
147 expected_channel: String,
149 actual_shape: String,
152 },
153 #[error(
158 "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
159 values {values:?}"
160 )]
161 VerdictValueNotInContract {
162 where_: String,
165 agent: String,
168 value: String,
173 values: Vec<String>,
176 },
177}
178
179pub trait SpawnerFactory: Send + Sync {
191 fn build(
194 &self,
195 agent_def: &AgentDef,
196 hint: Option<&Value>,
197 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
198}
199
200pub trait SpawnerFactoryKind: SpawnerFactory {
216 const KIND: AgentKind;
219 type Worker: crate::worker::Worker;
226}
227
228#[derive(Clone)]
231pub struct SpawnerRegistry {
232 factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
233}
234
235impl SpawnerRegistry {
236 pub fn new() -> Self {
238 Self {
239 factories: HashMap::new(),
240 }
241 }
242 pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
251 let f: Arc<dyn SpawnerFactory> = factory;
252 self.factories.insert(F::KIND, f);
253 self
254 }
255}
256
257impl Default for SpawnerRegistry {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263pub struct Compiler {
270 registry: SpawnerRegistry,
271 default_spawner: Option<Arc<dyn SpawnerAdapter>>,
272}
273
274pub struct CompiledBlueprint {
278 pub router: Arc<CompiledAgentTable>,
280 pub flow: FlowNode,
282 pub metadata: BlueprintMetadata,
284 pub step_naming: Arc<StepNaming>,
289 pub projection_placement: Arc<ProjectionPlacement>,
295}
296
297impl Compiler {
298 pub fn new(registry: SpawnerRegistry) -> Self {
302 Self {
303 registry,
304 default_spawner: None,
305 }
306 }
307
308 pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
312 self.default_spawner = Some(sp);
313 self
314 }
315
316 pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
321 let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
322 let mut seen: HashMap<String, ()> = HashMap::new();
323 let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
329
330 let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
336 for ad in &bp.agents {
337 if !matches!(ad.kind, AgentKind::Operator) {
338 continue;
339 }
340 let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
341 if let Some(op_ref) = op_ref {
342 if !defined.iter().any(|n| n == op_ref) {
343 return Err(CompileError::UnresolvedOperatorRef {
344 agent: ad.name.clone(),
345 op_ref: op_ref.to_string(),
346 defined: defined.clone(),
347 });
348 }
349 }
350 }
352
353 let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
357 for ad in &bp.agents {
358 let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
359 if let Some(meta_ref) = meta_ref {
360 if !metas_defined.iter().any(|n| n == meta_ref) {
361 return Err(CompileError::UnresolvedMetaRef {
362 where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
363 meta_ref: meta_ref.clone(),
364 defined: metas_defined.clone(),
365 });
366 }
367 }
368 }
369 let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
375 collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
376 for (where_, meta_ref) in static_step_meta_refs {
377 if !metas_defined.iter().any(|n| n == &meta_ref) {
378 return Err(CompileError::UnresolvedMetaRef {
379 where_,
380 meta_ref,
381 defined: metas_defined.clone(),
382 });
383 }
384 }
385
386 let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
391 for audit in &bp.audits {
392 if !agents_defined.iter().any(|n| n == &audit.agent) {
393 return Err(CompileError::UnresolvedAuditAgent {
394 agent: audit.agent.clone(),
395 defined: agents_defined.clone(),
396 });
397 }
398 }
399
400 for ad in &bp.agents {
401 if seen.contains_key(&ad.name) {
402 return Err(CompileError::DuplicateAgent(ad.name.clone()));
403 }
404 seen.insert(ad.name.clone(), ());
405
406 if let Some(contract) = &ad.verdict {
412 verdict_contracts.insert(ad.name.clone(), contract.clone());
413 }
414
415 let factory = match self.registry.factories.get(&ad.kind) {
416 Some(f) => f.clone(),
417 None => {
418 if bp.strategy.strict_kind {
419 return Err(CompileError::UnknownKind(ad.kind.clone()));
420 } else {
421 tracing::warn!(
422 agent = %ad.name,
423 kind = ?ad.kind,
424 "no spawner factory registered for agent kind; \
425 dropping agent from routing table (strict_kind=false)"
426 );
427 continue;
428 }
429 }
430 };
431 let hint = bp.hints.per_agent.get(&ad.name);
432 let spawner = factory.build(ad, hint)?;
433 routes.insert(ad.name.clone(), spawner);
434 }
435
436 verify_verdict_conds(&bp.flow, &verdict_contracts)?;
444
445 if bp.strategy.strict_refs {
446 verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
447 }
448
449 let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
457 for warning in &step_naming_warnings {
458 tracing::warn!(
459 name = %warning.name,
460 first_step_ref = %warning.first_step_ref,
461 second_step_ref = %warning.second_step_ref,
462 "StepNaming: undeclared steps' canonical/alias names collide; \
463 the step whose own ref matches the name keeps it (data-plane priority)"
464 );
465 }
466
467 let projection_placement =
475 ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
476
477 let router = Arc::new(CompiledAgentTable {
478 routes,
479 default: self.default_spawner.clone(),
480 verdict_contracts,
481 });
482 Ok(CompiledBlueprint {
483 router,
484 flow: bp.flow.clone(),
485 metadata: bp.metadata.clone(),
486 step_naming: Arc::new(step_naming),
487 projection_placement: Arc::new(projection_placement),
488 })
489 }
490}
491
492fn verify_refs(
495 node: &FlowNode,
496 routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
497 has_default: bool,
498) -> Result<(), CompileError> {
499 let mut refs: Vec<String> = Vec::new();
500 collect_refs(node, &mut refs);
501 for r in refs {
502 if !routes.contains_key(&r) && !has_default {
503 return Err(CompileError::UnresolvedRef(r));
504 }
505 }
506 Ok(())
507}
508
509fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
510 match node {
511 FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
512 FlowNode::Seq { children } => {
513 for c in children {
514 collect_refs(c, out);
515 }
516 }
517 FlowNode::Branch { then_, else_, .. } => {
518 collect_refs(then_, out);
519 collect_refs(else_, out);
520 }
521 FlowNode::Fanout { body, .. } => collect_refs(body, out),
522 FlowNode::Loop { body, .. } => collect_refs(body, out),
523 FlowNode::Try { body, catch, .. } => {
524 collect_refs(body, out);
525 collect_refs(catch, out);
526 }
527 FlowNode::Assign { .. } => {} }
529}
530
531fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
539 match node {
540 FlowNode::Step { ref_, in_, .. } => {
541 if let Expr::Lit { value } = in_ {
542 if let Some(meta_ref) = static_step_meta_ref(value) {
543 out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
544 }
545 }
546 }
547 FlowNode::Seq { children } => {
548 for c in children {
549 collect_step_meta_refs(c, out);
550 }
551 }
552 FlowNode::Branch { then_, else_, .. } => {
553 collect_step_meta_refs(then_, out);
554 collect_step_meta_refs(else_, out);
555 }
556 FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
557 FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
558 FlowNode::Try { body, catch, .. } => {
559 collect_step_meta_refs(body, out);
560 collect_step_meta_refs(catch, out);
561 }
562 FlowNode::Assign { .. } => {} }
564}
565
566fn static_step_meta_ref(value: &Value) -> Option<String> {
573 value
574 .as_object()?
575 .get("$step_meta")?
576 .as_object()?
577 .get("ref")?
578 .as_str()
579 .map(str::to_string)
580}
581
582fn verify_verdict_conds(
594 flow: &FlowNode,
595 verdict_contracts: &HashMap<String, VerdictContract>,
596) -> Result<(), CompileError> {
597 let mut step_outputs: HashMap<String, String> = HashMap::new();
598 collect_step_outputs(flow, &mut step_outputs);
599
600 let mut errors: Vec<CompileError> = Vec::new();
601 collect_verdict_conds(flow, &step_outputs, verdict_contracts, &mut errors);
602 match errors.into_iter().next() {
603 Some(e) => Err(e),
604 None => Ok(()),
605 }
606}
607
608fn collect_step_outputs(node: &FlowNode, out: &mut HashMap<String, String>) {
616 match node {
617 FlowNode::Step {
618 ref_,
619 out: out_expr,
620 ..
621 } => {
622 if let Expr::Path { at } = out_expr {
623 out.insert(at.to_string(), ref_.clone());
624 }
625 }
626 FlowNode::Seq { children } => {
627 for c in children {
628 collect_step_outputs(c, out);
629 }
630 }
631 FlowNode::Branch { then_, else_, .. } => {
632 collect_step_outputs(then_, out);
633 collect_step_outputs(else_, out);
634 }
635 FlowNode::Fanout { body, .. } => collect_step_outputs(body, out),
636 FlowNode::Loop { body, .. } => collect_step_outputs(body, out),
637 FlowNode::Try { body, catch, .. } => {
638 collect_step_outputs(body, out);
639 collect_step_outputs(catch, out);
640 }
641 FlowNode::Assign { .. } => {} }
643}
644
645fn collect_verdict_conds(
650 node: &FlowNode,
651 step_outputs: &HashMap<String, String>,
652 verdict_contracts: &HashMap<String, VerdictContract>,
653 errors: &mut Vec<CompileError>,
654) {
655 match node {
656 FlowNode::Branch { cond, then_, else_ } => {
657 lint_cond_expr(cond, "Branch cond", step_outputs, verdict_contracts, errors);
658 collect_verdict_conds(then_, step_outputs, verdict_contracts, errors);
659 collect_verdict_conds(else_, step_outputs, verdict_contracts, errors);
660 }
661 FlowNode::Loop { cond, body, .. } => {
662 lint_cond_expr(cond, "Loop cond", step_outputs, verdict_contracts, errors);
663 collect_verdict_conds(body, step_outputs, verdict_contracts, errors);
664 }
665 FlowNode::Seq { children } => {
666 for c in children {
667 collect_verdict_conds(c, step_outputs, verdict_contracts, errors);
668 }
669 }
670 FlowNode::Fanout { body, .. } => {
671 collect_verdict_conds(body, step_outputs, verdict_contracts, errors)
672 }
673 FlowNode::Try { body, catch, .. } => {
674 collect_verdict_conds(body, step_outputs, verdict_contracts, errors);
675 collect_verdict_conds(catch, step_outputs, verdict_contracts, errors);
676 }
677 FlowNode::Step { .. } | FlowNode::Assign { .. } => {}
678 }
679}
680
681fn lint_cond_expr(
690 expr: &Expr,
691 where_: &str,
692 step_outputs: &HashMap<String, String>,
693 verdict_contracts: &HashMap<String, VerdictContract>,
694 errors: &mut Vec<CompileError>,
695) {
696 match expr {
697 Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
698 if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
699 resolve_and_check(
700 path,
701 &[lit],
702 where_,
703 step_outputs,
704 verdict_contracts,
705 errors,
706 );
707 }
708 }
709 Expr::In { needle, haystack } => {
710 if let (
711 Expr::Path { at },
712 Expr::Lit {
713 value: Value::Array(items),
714 },
715 ) = (needle.as_ref(), haystack.as_ref())
716 {
717 let lits: Vec<&Value> = items.iter().collect();
718 resolve_and_check(at, &lits, where_, step_outputs, verdict_contracts, errors);
719 }
720 }
721 Expr::And { args } | Expr::Or { args } => {
722 for a in args {
723 lint_cond_expr(a, where_, step_outputs, verdict_contracts, errors);
724 }
725 }
726 Expr::Not { arg } => lint_cond_expr(arg, where_, step_outputs, verdict_contracts, errors),
727 _ => {}
728 }
729}
730
731fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
737 match (lhs, rhs) {
738 (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
739 (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
740 _ => None,
741 }
742}
743
744fn resolve_and_check(
759 path: &Path,
760 lits: &[&Value],
761 where_: &str,
762 step_outputs: &HashMap<String, String>,
763 verdict_contracts: &HashMap<String, VerdictContract>,
764 errors: &mut Vec<CompileError>,
765) {
766 let path_str = path.to_string();
767 let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
768 (agent, "body")
769 } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
770 match step_outputs.get(stripped) {
771 Some(agent) => (agent, "part"),
772 None => return,
773 }
774 } else {
775 return;
776 };
777
778 let Some(contract) = verdict_contracts.get(agent) else {
779 tracing::warn!(
780 agent = %agent,
781 where_ = %where_,
782 "cond references agent output but no verdict contract declared"
783 );
784 return;
785 };
786
787 let expected_channel = match contract.channel {
788 VerdictChannel::Body => "body",
789 VerdictChannel::Part => "part",
790 };
791 if expected_channel != actual_shape {
792 errors.push(CompileError::VerdictChannelMismatch {
793 where_: where_.to_string(),
794 agent: agent.clone(),
795 expected_channel: expected_channel.to_string(),
796 actual_shape: actual_shape.to_string(),
797 });
798 return;
799 }
800
801 for lit in lits {
802 let value_str = lit
803 .as_str()
804 .map(str::to_string)
805 .unwrap_or_else(|| lit.to_string());
806 if !contract.values.iter().any(|v| v == &value_str) {
807 errors.push(CompileError::VerdictValueNotInContract {
808 where_: where_.to_string(),
809 agent: agent.clone(),
810 value: value_str,
811 values: contract.values.clone(),
812 });
813 }
814 }
815}
816
817pub struct CompiledAgentTable {
830 pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
831 pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
832 pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
836}
837
838impl CompiledAgentTable {
839 pub fn has_route(&self, agent: &str) -> bool {
842 self.routes.contains_key(agent)
843 }
844 pub fn routed_agents(&self) -> Vec<String> {
846 self.routes.keys().cloned().collect()
847 }
848 pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
852 self.verdict_contracts.get(agent)
853 }
854}
855
856#[async_trait]
857impl SpawnerAdapter for CompiledAgentTable {
858 async fn spawn(
859 &self,
860 engine: &Engine,
861 ctx: &Ctx,
862 task_id: StepId,
863 attempt: u32,
864 token: CapToken,
865 ) -> Result<Box<dyn Worker>, SpawnError> {
866 let sp = self
867 .routes
868 .get(&ctx.agent)
869 .cloned()
870 .or_else(|| self.default.clone())
871 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
872 sp.spawn(engine, ctx, task_id, attempt, token).await
873 }
874}
875
876pub struct SubprocessProcessSpawnerFactory;
894
895impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
896 const KIND: AgentKind = AgentKind::Subprocess;
897 type Worker = crate::worker::process_spawner::ProcessWorker;
898}
899
900impl SpawnerFactory for SubprocessProcessSpawnerFactory {
901 fn build(
902 &self,
903 agent_def: &AgentDef,
904 _hint: Option<&Value>,
905 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
906 let agent_name = &agent_def.name;
907 let spec = &agent_def.spec;
908 let invalid = |msg: String| CompileError::InvalidSpec {
909 name: agent_name.to_string(),
910 msg,
911 };
912 let program = spec
913 .get("program")
914 .and_then(|v| v.as_str())
915 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
916 .to_string();
917 let args: Vec<String> = spec
918 .get("args")
919 .and_then(|v| v.as_array())
920 .map(|a| {
921 a.iter()
922 .filter_map(|x| x.as_str().map(|s| s.to_string()))
923 .collect()
924 })
925 .unwrap_or_default();
926 let use_stdin = spec
927 .get("use_stdin")
928 .and_then(|v| v.as_bool())
929 .unwrap_or(true);
930 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
931 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
932 Some("sse_events") => Some(StreamMode::SseEvents),
933 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
934 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
935 None => None,
936 };
937
938 let mut sp = ProcessSpawner {
939 program,
940 args,
941 use_stdin,
942 stream_mode,
943 };
944 if let Some(mode) = sp.stream_mode.clone() {
945 sp = sp.stream_mode(mode);
946 }
947 Ok(Arc::new(sp))
948 }
949}
950
951pub struct LuaInProcessSpawnerFactory {
982 registry: HashMap<String, WorkerFn>,
983 bridges: HashMap<String, HostBridge>,
984}
985
986#[derive(Clone)]
998pub struct HostBridge(
999 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
1000);
1001
1002impl HostBridge {
1003 pub fn new<F>(f: F) -> Self
1005 where
1006 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
1007 {
1008 Self(Arc::new(f))
1009 }
1010
1011 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
1015 (self.0)(arg)
1016 }
1017}
1018
1019#[derive(Clone)]
1026pub struct LuaScriptSource {
1027 pub source: String,
1029 pub label: String,
1032}
1033
1034impl LuaScriptSource {
1035 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
1037 Self {
1038 source: source.into(),
1039 label: label.into(),
1040 }
1041 }
1042}
1043
1044impl LuaInProcessSpawnerFactory {
1045 pub fn new() -> Self {
1047 Self {
1048 registry: HashMap::new(),
1049 bridges: HashMap::new(),
1050 }
1051 }
1052
1053 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
1060 self.bridges.insert(name.into(), bridge);
1061 self
1062 }
1063
1064 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
1082 let source = Arc::new(source);
1083 let bridges = Arc::new(self.bridges.clone());
1084 let wrapped: WorkerFn = Arc::new(move |inv| {
1085 let source = source.clone();
1086 let bridges = bridges.clone();
1087 Box::pin(run_lua_worker(source, bridges, inv))
1088 });
1089 self.registry.insert(fn_id.into(), wrapped);
1090 self
1091 }
1092}
1093
1094async fn run_lua_worker(
1096 source: Arc<LuaScriptSource>,
1097 bridges: Arc<HashMap<String, HostBridge>>,
1098 inv: crate::worker::adapter::WorkerInvocation,
1099) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
1100 use crate::worker::adapter::WorkerError;
1101 use mlua::LuaSerdeExt;
1102
1103 let label = source.label.clone();
1104 let outcome =
1105 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
1106 let lua = mlua::Lua::new();
1107 let g = lua.globals();
1108
1109 g.set("_PROMPT", inv.prompt.clone())
1111 .map_err(|e| format!("set _PROMPT: {e}"))?;
1112 g.set("_AGENT", inv.agent.clone())
1113 .map_err(|e| format!("set _AGENT: {e}"))?;
1114 g.set("_TASK_ID", inv.task_id.to_string())
1115 .map_err(|e| format!("set _TASK_ID: {e}"))?;
1116 g.set("_ATTEMPT", inv.attempt as i64)
1117 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
1118
1119 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
1121 let lua_val = lua
1122 .to_value(&json_val)
1123 .map_err(|e| format!("_CTX to_value: {e}"))?;
1124 g.set("_CTX", lua_val)
1125 .map_err(|e| format!("set _CTX: {e}"))?;
1126 }
1127
1128 if !bridges.is_empty() {
1130 let host = lua
1131 .create_table()
1132 .map_err(|e| format!("create host table: {e}"))?;
1133 for (name, bridge) in bridges.iter() {
1134 let bridge = bridge.clone();
1135 let bname = name.clone();
1136 let f = lua
1137 .create_function(move |lua, arg: mlua::Value| {
1138 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
1139 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
1140 })?;
1141 let result_json =
1142 bridge.call(json_arg).map_err(mlua::Error::external)?;
1143 lua.to_value(&result_json).map_err(|e| {
1144 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
1145 })
1146 })
1147 .map_err(|e| format!("create_function {name}: {e}"))?;
1148 host.set(name.as_str(), f)
1149 .map_err(|e| format!("host.{name} set: {e}"))?;
1150 }
1151 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
1152 }
1153
1154 let result: mlua::Value = lua
1156 .load(&source.source)
1157 .set_name(&source.label)
1158 .eval()
1159 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
1160
1161 let json_result: serde_json::Value = lua
1163 .from_value(result)
1164 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
1165
1166 let (value, ok) = match &json_result {
1167 serde_json::Value::Object(map)
1168 if map.contains_key("value") || map.contains_key("ok") =>
1169 {
1170 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
1171 let value = map.get("value").cloned().unwrap_or(json_result.clone());
1172 (value, ok)
1173 }
1174 _ => (json_result, true),
1175 };
1176 Ok((value, ok))
1177 })
1178 .await
1179 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
1180 .map_err(WorkerError::Failed)?;
1181
1182 Ok(crate::worker::adapter::WorkerResult {
1183 value: outcome.0,
1184 ok: outcome.1,
1185 })
1186}
1187
1188impl Default for LuaInProcessSpawnerFactory {
1189 fn default() -> Self {
1190 Self::new()
1191 }
1192}
1193
1194impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
1195 const KIND: AgentKind = AgentKind::Lua;
1196 type Worker = LuaWorker;
1197}
1198
1199impl SpawnerFactory for LuaInProcessSpawnerFactory {
1200 fn build(
1201 &self,
1202 agent_def: &AgentDef,
1203 _hint: Option<&Value>,
1204 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1205 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
1211 let label = agent_def
1212 .spec
1213 .get("label")
1214 .and_then(|v| v.as_str())
1215 .map(str::to_string)
1216 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
1217 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
1218 let bridges = Arc::new(self.bridges.clone());
1219 let wrapped: WorkerFn = Arc::new(move |inv| {
1220 let source = script.clone();
1221 let bridges = bridges.clone();
1222 Box::pin(run_lua_worker(source, bridges, inv))
1223 });
1224 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
1225 sp.registry.insert(agent_def.name.to_string(), wrapped);
1226 return Ok(Arc::new(sp));
1227 }
1228 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
1229 }
1230}
1231
1232pub struct RustFnInProcessSpawnerFactory {
1246 registry: HashMap<String, WorkerFn>,
1247}
1248
1249impl RustFnInProcessSpawnerFactory {
1250 pub fn new() -> Self {
1252 Self {
1253 registry: HashMap::new(),
1254 }
1255 }
1256
1257 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
1260 where
1261 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
1262 Fut: std::future::Future<
1263 Output = Result<
1264 crate::worker::adapter::WorkerResult,
1265 crate::worker::adapter::WorkerError,
1266 >,
1267 > + Send
1268 + 'static,
1269 {
1270 let f = Arc::new(f);
1271 let wrapped: WorkerFn = Arc::new(move |inv| {
1272 let f = f.clone();
1273 Box::pin(f(inv))
1274 });
1275 self.registry.insert(fn_id.into(), wrapped);
1276 self
1277 }
1278}
1279
1280impl Default for RustFnInProcessSpawnerFactory {
1281 fn default() -> Self {
1282 Self::new()
1283 }
1284}
1285
1286impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
1287 const KIND: AgentKind = AgentKind::RustFn;
1288 type Worker = RustFnWorker;
1289}
1290
1291impl SpawnerFactory for RustFnInProcessSpawnerFactory {
1292 fn build(
1293 &self,
1294 agent_def: &AgentDef,
1295 _hint: Option<&Value>,
1296 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1297 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
1298 }
1299}
1300
1301fn build_inproc_from_registry<W>(
1307 registry: &HashMap<String, WorkerFn>,
1308 agent_def: &AgentDef,
1309 kind_label: &str,
1310) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
1311where
1312 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
1313{
1314 let agent_name = &agent_def.name;
1315 let spec = &agent_def.spec;
1316 let invalid = |msg: String| CompileError::InvalidSpec {
1317 name: agent_name.to_string(),
1318 msg,
1319 };
1320 let fn_id = spec
1321 .get("fn_id")
1322 .and_then(|v| v.as_str())
1323 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
1324 let f = registry
1325 .get(fn_id)
1326 .cloned()
1327 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
1328 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
1329 sp.registry.insert(agent_name.to_string(), f);
1333 Ok(Arc::new(sp))
1334}
1335
1336pub struct LuaWorker {
1341 pub handler: crate::worker::WorkerJoinHandler,
1343}
1344
1345impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
1346 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1347 Self { handler }
1348 }
1349}
1350
1351#[async_trait::async_trait]
1352impl crate::worker::Worker for LuaWorker {
1353 fn id(&self) -> &crate::types::WorkerId {
1354 &self.handler.worker_id
1355 }
1356 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1357 self.handler.cancel.clone()
1358 }
1359 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1360 self.handler.await_completion().await
1361 }
1362}
1363
1364pub struct RustFnWorker {
1369 pub handler: crate::worker::WorkerJoinHandler,
1371}
1372
1373impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
1374 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1375 Self { handler }
1376 }
1377}
1378
1379#[async_trait::async_trait]
1380impl crate::worker::Worker for RustFnWorker {
1381 fn id(&self) -> &crate::types::WorkerId {
1382 &self.handler.worker_id
1383 }
1384 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1385 self.handler.cancel.clone()
1386 }
1387 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1388 self.handler.await_completion().await
1389 }
1390}
1391
1392pub struct OperatorSpawnerFactory {
1445 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
1446}
1447
1448impl OperatorSpawnerFactory {
1449 pub fn new() -> Self {
1451 Self {
1452 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
1453 }
1454 }
1455
1456 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
1462 self.operators
1463 .write()
1464 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1465 .insert(id.into(), op);
1466 self
1467 }
1468
1469 pub fn unregister_operator(&self, id: &str) -> &Self {
1472 self.operators
1473 .write()
1474 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1475 .remove(id);
1476 self
1477 }
1478}
1479
1480impl Default for OperatorSpawnerFactory {
1481 fn default() -> Self {
1482 Self::new()
1483 }
1484}
1485
1486impl SpawnerFactoryKind for OperatorSpawnerFactory {
1487 const KIND: AgentKind = AgentKind::Operator;
1488 type Worker = crate::operator::OperatorWorker;
1489}
1490
1491impl SpawnerFactory for OperatorSpawnerFactory {
1492 fn build(
1493 &self,
1494 agent_def: &AgentDef,
1495 _hint: Option<&Value>,
1496 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1497 let agent_name = &agent_def.name;
1498 let spec = &agent_def.spec;
1499 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
1505 let invalid = |msg: String| CompileError::InvalidSpec {
1506 name: agent_name.to_string(),
1507 msg,
1508 };
1509 let op_ref = spec
1510 .get("operator_ref")
1511 .and_then(|v| v.as_str())
1512 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
1513 let operators = self
1514 .operators
1515 .read()
1516 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
1517 let op = operators.get(op_ref).cloned().ok_or_else(|| {
1518 let mut names: Vec<String> = operators.keys().cloned().collect();
1519 names.sort();
1520 let names_list = if names.is_empty() {
1521 "<none>".to_string()
1522 } else {
1523 names.join(", ")
1524 };
1525 invalid(format!(
1526 "operator_ref '{op_ref}' not registered in factory. \
1527 Registered sids: [{names_list}]. \
1528 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
1529 ))
1530 })?;
1531 drop(operators);
1532
1533 let worker_binding = agent_def
1540 .profile
1541 .as_ref()
1542 .and_then(|p| p.worker_binding.as_ref())
1543 .map(|variant| WorkerBinding {
1544 variant: variant.clone(),
1545 tools: agent_def
1546 .profile
1547 .as_ref()
1548 .map(|p| p.tools.clone())
1549 .unwrap_or_default(),
1550 });
1551 if op.requires_worker_binding() && worker_binding.is_none() {
1552 return Err(invalid(
1557 "profile.worker_binding is required for this operator backend. \
1558 Fix by either: \
1559 (a) if authoring the Blueprint JSON directly, add \
1560 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
1561 to the JSON literal; or \
1562 (b) if using an $agent_md file ref, add \
1563 `worker_binding: <subagent-type>` to the agent .md frontmatter."
1564 .into(),
1565 ));
1566 }
1567 Ok(Arc::new(OperatorSpawner::new(
1568 op,
1569 system_prompt,
1570 worker_binding,
1571 )))
1572 }
1573}
1574
1575#[cfg(test)]
1576mod operator_spawner_factory_worker_binding_tests {
1577 use super::*;
1578 use crate::blueprint::AgentProfile;
1579 use crate::core::ctx::Ctx;
1580 use crate::types::CapToken;
1581 use crate::worker::adapter::{WorkerError, WorkerResult};
1582
1583 struct StubOperator {
1588 requires_binding: bool,
1589 }
1590
1591 #[async_trait]
1592 impl Operator for StubOperator {
1593 async fn execute(
1594 &self,
1595 _ctx: &Ctx,
1596 _system: Option<String>,
1597 _prompt: Value,
1598 _worker: Option<WorkerBinding>,
1599 _worker_token: CapToken,
1600 ) -> Result<WorkerResult, WorkerError> {
1601 Ok(WorkerResult {
1602 value: Value::Null,
1603 ok: true,
1604 })
1605 }
1606
1607 fn requires_worker_binding(&self) -> bool {
1608 self.requires_binding
1609 }
1610 }
1611
1612 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
1613 AgentDef {
1614 name: "test-agent".to_string(),
1615 kind: AgentKind::Operator,
1616 spec: serde_json::json!({ "operator_ref": "op1" }),
1617 profile,
1618 meta: None,
1619 runner: None,
1620 runner_ref: None,
1621 verdict: None,
1622 }
1623 }
1624
1625 #[test]
1626 fn build_fails_loud_when_binding_required_but_absent() {
1627 let factory = OperatorSpawnerFactory::new();
1628 factory.register_operator(
1629 "op1",
1630 Arc::new(StubOperator {
1631 requires_binding: true,
1632 }) as Arc<dyn Operator>,
1633 );
1634 let def = agent_def_with(Some(AgentProfile::default()));
1635 match factory.build(&def, None) {
1636 Err(CompileError::InvalidSpec { name, msg }) => {
1637 assert_eq!(name, "test-agent");
1638 assert!(
1639 msg.contains("worker_binding is required"),
1640 "unexpected message: {msg}"
1641 );
1642 assert!(
1646 msg.contains("agents[N].profile.worker_binding"),
1647 "message missing JSON-direct hint (issue #9): {msg}"
1648 );
1649 assert!(
1650 msg.contains("agent .md frontmatter"),
1651 "message missing $agent_md hint: {msg}"
1652 );
1653 }
1654 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
1655 Ok(_) => panic!("expected compile-time failure, got Ok"),
1656 }
1657 }
1658
1659 #[test]
1660 fn build_succeeds_when_binding_required_and_present() {
1661 let factory = OperatorSpawnerFactory::new();
1662 factory.register_operator(
1663 "op1",
1664 Arc::new(StubOperator {
1665 requires_binding: true,
1666 }) as Arc<dyn Operator>,
1667 );
1668 let profile = AgentProfile {
1669 worker_binding: Some("mse-worker-coder".to_string()),
1670 tools: vec!["Read".to_string(), "Edit".to_string()],
1671 ..Default::default()
1672 };
1673 let def = agent_def_with(Some(profile));
1674 assert!(
1675 factory.build(&def, None).is_ok(),
1676 "expected Ok when worker_binding is declared"
1677 );
1678 }
1679
1680 #[test]
1681 fn build_succeeds_when_binding_not_required_and_absent() {
1682 let factory = OperatorSpawnerFactory::new();
1683 factory.register_operator(
1684 "op1",
1685 Arc::new(StubOperator {
1686 requires_binding: false,
1687 }) as Arc<dyn Operator>,
1688 );
1689 let def = agent_def_with(Some(AgentProfile::default()));
1690 assert!(
1691 factory.build(&def, None).is_ok(),
1692 "backends that don't require a binding must not be gated by its absence"
1693 );
1694 }
1695}
1696
1697#[cfg(test)]
1705mod lua_inline_source_tests {
1706 use super::*;
1707 use crate::types::{CapToken, Role, StepId};
1708
1709 fn agent(name: &str, spec: Value) -> AgentDef {
1710 AgentDef {
1711 name: name.to_string(),
1712 kind: AgentKind::Lua,
1713 spec,
1714 profile: None,
1715 meta: None,
1716 runner: None,
1717 runner_ref: None,
1718 verdict: None,
1719 }
1720 }
1721
1722 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
1723 crate::worker::adapter::WorkerInvocation {
1724 token: CapToken {
1725 agent_id: "a".into(),
1726 role: Role::Worker,
1727 scopes: vec!["*".into()],
1728 issued_at: 0,
1729 expire_at: u64::MAX / 2,
1730 max_uses: None,
1731 nonce: "test-nonce".into(),
1732 sig_hex: "".into(),
1733 },
1734 task_id: StepId::parse("ST-test").expect("StepId parse"),
1735 attempt: 1,
1736 agent: "g".into(),
1737 prompt: prompt.into(),
1738 sink: None,
1739 cancel_token: None,
1740 }
1741 }
1742
1743 #[test]
1744 fn build_accepts_inline_source_without_pre_registration() {
1745 let factory = LuaInProcessSpawnerFactory::new();
1746 let def = agent(
1747 "g",
1748 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
1749 );
1750 assert!(
1751 factory.build(&def, None).is_ok(),
1752 "inline spec.source must build without a pre-registered fn_id"
1753 );
1754 }
1755
1756 #[test]
1757 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
1758 let factory = LuaInProcessSpawnerFactory::new();
1759 let def = agent("g", serde_json::json!({}));
1760 match factory.build(&def, None) {
1761 Err(CompileError::InvalidSpec { msg, .. }) => {
1762 assert!(
1763 msg.contains("fn_id"),
1764 "empty spec must still surface the fn_id-required message: {msg}"
1765 );
1766 }
1767 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
1768 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
1771 }
1772 }
1773
1774 #[tokio::test]
1778 async fn inline_source_evaluates_and_marshals_result() {
1779 let source =
1780 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
1781 let out = run_lua_worker(
1782 std::sync::Arc::new(source),
1783 std::sync::Arc::new(HashMap::new()),
1784 test_invocation("hello"),
1785 )
1786 .await
1787 .expect("lua worker ok");
1788 assert_eq!(out.value, serde_json::json!("hello!"));
1789 assert!(out.ok);
1790 }
1791
1792 #[tokio::test]
1793 async fn inline_source_can_signal_agent_level_failure() {
1794 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
1797 let out = run_lua_worker(
1798 std::sync::Arc::new(source),
1799 std::sync::Arc::new(HashMap::new()),
1800 test_invocation("input"),
1801 )
1802 .await
1803 .expect("lua worker ok");
1804 assert_eq!(out.value, serde_json::json!("nope"));
1805 assert!(!out.ok);
1806 }
1807}
1808
1809#[cfg(test)]
1812mod meta_ref_validation_tests {
1813 use super::*;
1814 use crate::blueprint::{AgentMeta, MetaDef};
1815 use crate::worker::adapter::WorkerResult;
1816
1817 fn registry_with_echo() -> SpawnerRegistry {
1818 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1819 Ok(WorkerResult {
1820 value: Value::String(inv.prompt),
1821 ok: true,
1822 })
1823 });
1824 let mut reg = SpawnerRegistry::new();
1825 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1826 reg
1827 }
1828
1829 fn rustfn_agent(name: &str) -> AgentDef {
1830 AgentDef {
1831 name: name.to_string(),
1832 kind: AgentKind::RustFn,
1833 spec: serde_json::json!({ "fn_id": "echo" }),
1834 profile: None,
1835 meta: None,
1836 runner: None,
1837 runner_ref: None,
1838 verdict: None,
1839 }
1840 }
1841
1842 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
1843 FlowNode::Step {
1844 ref_: agent_ref.to_string(),
1845 in_,
1846 out: Expr::Path {
1847 at: "$.output".parse().expect("literal test path: $.output"),
1848 },
1849 }
1850 }
1851
1852 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
1853 Blueprint {
1854 schema_version: crate::blueprint::current_schema_version(),
1855 id: "meta-ref-ut".into(),
1856 flow,
1857 agents,
1858 operators: vec![],
1859 metas,
1860 hints: Default::default(),
1861 strategy: Default::default(),
1862 metadata: BlueprintMetadata::default(),
1863 spawner_hints: Default::default(),
1864 default_agent_kind: AgentKind::Operator,
1865 default_operator_kind: None,
1866 default_init_ctx: None,
1867 default_agent_ctx: None,
1868 default_context_policy: None,
1869 projection_placement: None,
1870 audits: vec![],
1871 degradation_policy: None,
1872 runners: vec![],
1873 default_runner: None,
1874 }
1875 }
1876
1877 #[test]
1878 fn valid_meta_ref_compiles() {
1879 let mut agent = rustfn_agent("worker");
1880 agent.meta = Some(AgentMeta {
1881 meta_ref: Some("shared".to_string()),
1882 ..Default::default()
1883 });
1884 let bp = minimal_bp(
1885 vec![agent],
1886 vec![MetaDef {
1887 name: "shared".into(),
1888 ctx: serde_json::json!({ "k": "v" }),
1889 }],
1890 simple_flow(
1891 "worker",
1892 Expr::Path {
1893 at: "$.input".parse().expect("literal test path: $.input"),
1894 },
1895 ),
1896 );
1897 let compiler = Compiler::new(registry_with_echo());
1898 assert!(
1899 compiler.compile(&bp).is_ok(),
1900 "a resolvable AgentMeta.meta_ref must compile"
1901 );
1902 }
1903
1904 #[test]
1905 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
1906 let mut agent = rustfn_agent("worker");
1907 agent.meta = Some(AgentMeta {
1908 meta_ref: Some("missing".to_string()),
1909 ..Default::default()
1910 });
1911 let bp = minimal_bp(
1912 vec![agent],
1913 vec![],
1914 simple_flow(
1915 "worker",
1916 Expr::Path {
1917 at: "$.input".parse().expect("literal test path: $.input"),
1918 },
1919 ),
1920 );
1921 let compiler = Compiler::new(registry_with_echo());
1922 match compiler.compile(&bp) {
1923 Err(CompileError::UnresolvedMetaRef {
1924 where_,
1925 meta_ref,
1926 defined,
1927 }) => {
1928 assert!(
1929 where_.contains("worker"),
1930 "where_ must name the agent: {where_}"
1931 );
1932 assert_eq!(meta_ref, "missing");
1933 assert!(defined.is_empty());
1934 }
1935 Err(other) => {
1936 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1937 }
1938 Ok(_) => panic!("expected compile-time failure, got Ok"),
1939 }
1940 }
1941
1942 #[test]
1943 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
1944 let agent = rustfn_agent("worker");
1945 let in_ = Expr::Lit {
1946 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
1947 };
1948 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
1949 let compiler = Compiler::new(registry_with_echo());
1950 match compiler.compile(&bp) {
1951 Err(CompileError::UnresolvedMetaRef {
1952 where_, meta_ref, ..
1953 }) => {
1954 assert!(
1955 where_.contains("worker"),
1956 "where_ must name the offending step: {where_}"
1957 );
1958 assert_eq!(meta_ref, "missing");
1959 }
1960 Err(other) => {
1961 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1962 }
1963 Ok(_) => panic!("expected compile-time failure, got Ok"),
1964 }
1965 }
1966
1967 #[test]
1968 fn path_op_input_with_no_static_envelope_compiles_fine() {
1969 let agent = rustfn_agent("worker");
1970 let bp = minimal_bp(
1971 vec![agent],
1972 vec![],
1973 simple_flow(
1974 "worker",
1975 Expr::Path {
1976 at: "$.input".parse().expect("literal test path: $.input"),
1977 },
1978 ),
1979 );
1980 let compiler = Compiler::new(registry_with_echo());
1981 assert!(
1982 compiler.compile(&bp).is_ok(),
1983 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
1984 );
1985 }
1986}
1987
1988#[cfg(test)]
1990mod audit_agent_validation_tests {
1991 use super::*;
1992 use crate::worker::adapter::WorkerResult;
1993 use mlua_swarm_schema::{AuditDef, AuditMode};
1994
1995 fn registry_with_echo() -> SpawnerRegistry {
1996 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1997 Ok(WorkerResult {
1998 value: Value::String(inv.prompt),
1999 ok: true,
2000 })
2001 });
2002 let mut reg = SpawnerRegistry::new();
2003 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2004 reg
2005 }
2006
2007 fn rustfn_agent(name: &str) -> AgentDef {
2008 AgentDef {
2009 name: name.to_string(),
2010 kind: AgentKind::RustFn,
2011 spec: serde_json::json!({ "fn_id": "echo" }),
2012 profile: None,
2013 meta: None,
2014 runner: None,
2015 runner_ref: None,
2016 verdict: None,
2017 }
2018 }
2019
2020 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
2021 Blueprint {
2022 schema_version: crate::blueprint::current_schema_version(),
2023 id: "audit-ref-ut".into(),
2024 flow: FlowNode::Step {
2025 ref_: "worker".to_string(),
2026 in_: Expr::Path {
2027 at: "$.input".parse().expect("literal test path: $.input"),
2028 },
2029 out: Expr::Path {
2030 at: "$.output".parse().expect("literal test path: $.output"),
2031 },
2032 },
2033 agents,
2034 operators: vec![],
2035 metas: vec![],
2036 hints: Default::default(),
2037 strategy: Default::default(),
2038 metadata: BlueprintMetadata::default(),
2039 spawner_hints: Default::default(),
2040 default_agent_kind: AgentKind::Operator,
2041 default_operator_kind: None,
2042 default_init_ctx: None,
2043 default_agent_ctx: None,
2044 default_context_policy: None,
2045 projection_placement: None,
2046 audits,
2047 degradation_policy: None,
2048 runners: vec![],
2049 default_runner: None,
2050 }
2051 }
2052
2053 #[test]
2054 fn unresolved_audit_agent_is_a_loud_compile_error() {
2055 let bp = minimal_bp(
2056 vec![rustfn_agent("worker")],
2057 vec![AuditDef {
2058 agent: "missing-auditor".to_string(),
2059 steps: None,
2060 mode: AuditMode::default(),
2061 }],
2062 );
2063 let compiler = Compiler::new(registry_with_echo());
2064 match compiler.compile(&bp) {
2065 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
2066 assert_eq!(agent, "missing-auditor");
2067 assert_eq!(defined, vec!["worker".to_string()]);
2068 }
2069 Err(other) => {
2070 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
2071 }
2072 Ok(_) => panic!("expected compile-time failure, got Ok"),
2073 }
2074 }
2075
2076 #[test]
2077 fn resolved_audit_agent_compiles_fine() {
2078 let bp = minimal_bp(
2079 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
2080 vec![AuditDef {
2081 agent: "auditor".to_string(),
2082 steps: None,
2083 mode: AuditMode::default(),
2084 }],
2085 );
2086 let compiler = Compiler::new(registry_with_echo());
2087 assert!(
2088 compiler.compile(&bp).is_ok(),
2089 "an audits[].agent that names a declared AgentDef must compile"
2090 );
2091 }
2092}
2093
2094#[cfg(test)]
2097mod projection_placement_compile_tests {
2098 use super::*;
2099 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
2100 use crate::worker::adapter::WorkerResult;
2101 use mlua_swarm_schema::ProjectionPlacementSpec;
2102
2103 fn registry_with_echo() -> SpawnerRegistry {
2104 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2105 Ok(WorkerResult {
2106 value: Value::String(inv.prompt),
2107 ok: true,
2108 })
2109 });
2110 let mut reg = SpawnerRegistry::new();
2111 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2112 reg
2113 }
2114
2115 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
2116 Blueprint {
2117 schema_version: crate::blueprint::current_schema_version(),
2118 id: "projection-placement-ut".into(),
2119 flow: FlowNode::Step {
2120 ref_: "worker".to_string(),
2121 in_: Expr::Path {
2122 at: "$.input".parse().expect("literal test path: $.input"),
2123 },
2124 out: Expr::Path {
2125 at: "$.output".parse().expect("literal test path: $.output"),
2126 },
2127 },
2128 agents: vec![AgentDef {
2129 name: "worker".to_string(),
2130 kind: AgentKind::RustFn,
2131 spec: serde_json::json!({ "fn_id": "echo" }),
2132 profile: None,
2133 meta: None,
2134 runner: None,
2135 runner_ref: None,
2136 verdict: None,
2137 }],
2138 operators: vec![],
2139 metas: vec![],
2140 hints: Default::default(),
2141 strategy: Default::default(),
2142 metadata: BlueprintMetadata::default(),
2143 spawner_hints: Default::default(),
2144 default_agent_kind: AgentKind::Operator,
2145 default_operator_kind: None,
2146 default_init_ctx: None,
2147 default_agent_ctx: None,
2148 default_context_policy: None,
2149 projection_placement,
2150 audits: vec![],
2151 degradation_policy: None,
2152 runners: vec![],
2153 default_runner: None,
2154 }
2155 }
2156
2157 #[test]
2158 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
2159 let bp = minimal_bp(None);
2160 let compiled = Compiler::new(registry_with_echo())
2161 .compile(&bp)
2162 .expect("undeclared projection_placement compiles");
2163 assert_eq!(
2164 *compiled.projection_placement,
2165 ProjectionPlacement::default()
2166 );
2167 }
2168
2169 #[test]
2170 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
2171 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2172 root: Some("project_root".to_string()),
2173 dir_template: Some("custom/{task_id}/out".to_string()),
2174 }));
2175 let compiled = Compiler::new(registry_with_echo())
2176 .compile(&bp)
2177 .expect("valid projection_placement compiles");
2178 assert_eq!(
2179 compiled.projection_placement.root_preference,
2180 RootPreference::ProjectRoot
2181 );
2182 assert_eq!(
2183 compiled.projection_placement.dir_template,
2184 "custom/{task_id}/out"
2185 );
2186 }
2187
2188 #[test]
2189 fn declared_invalid_dir_template_rejects_compile() {
2190 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2191 root: None,
2192 dir_template: Some("workspace/tasks/ctx".to_string()), }));
2194 match Compiler::new(registry_with_echo()).compile(&bp) {
2195 Err(CompileError::InvalidProjectionPlacement(_)) => {}
2196 Err(other) => {
2197 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2198 }
2199 Ok(_) => {
2200 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
2201 }
2202 }
2203 }
2204
2205 #[test]
2206 fn declared_invalid_root_literal_rejects_compile() {
2207 let bp = minimal_bp(Some(ProjectionPlacementSpec {
2208 root: Some("nope".to_string()),
2209 dir_template: None,
2210 }));
2211 match Compiler::new(registry_with_echo()).compile(&bp) {
2212 Err(CompileError::InvalidProjectionPlacement(_)) => {}
2213 Err(other) => {
2214 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
2215 }
2216 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
2217 }
2218 }
2219}
2220
2221#[cfg(test)]
2223mod verdict_contract_lint_tests {
2224 use super::*;
2225 use crate::worker::adapter::WorkerResult;
2226
2227 fn registry_with_echo() -> SpawnerRegistry {
2228 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2229 Ok(WorkerResult {
2230 value: Value::String(inv.prompt),
2231 ok: true,
2232 })
2233 });
2234 let mut reg = SpawnerRegistry::new();
2235 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2236 reg
2237 }
2238
2239 fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
2240 AgentDef {
2241 name: "gate".to_string(),
2242 kind: AgentKind::RustFn,
2243 spec: serde_json::json!({ "fn_id": "echo" }),
2244 profile: None,
2245 meta: None,
2246 runner: None,
2247 runner_ref: None,
2248 verdict,
2249 }
2250 }
2251
2252 fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
2253 Blueprint {
2254 schema_version: crate::blueprint::current_schema_version(),
2255 id: "verdict-contract-ut".into(),
2256 flow,
2257 agents: vec![agent],
2258 operators: vec![],
2259 metas: vec![],
2260 hints: Default::default(),
2261 strategy: Default::default(),
2262 metadata: BlueprintMetadata::default(),
2263 spawner_hints: Default::default(),
2264 default_agent_kind: AgentKind::Operator,
2265 default_operator_kind: None,
2266 default_init_ctx: None,
2267 default_agent_ctx: None,
2268 default_context_policy: None,
2269 projection_placement: None,
2270 audits: vec![],
2271 degradation_policy: None,
2272 runners: vec![],
2273 default_runner: None,
2274 }
2275 }
2276
2277 fn step(ref_: &str, out_path: &str) -> FlowNode {
2278 FlowNode::Step {
2279 ref_: ref_.to_string(),
2280 in_: Expr::Lit { value: Value::Null },
2281 out: Expr::Path {
2282 at: out_path.parse().expect("literal test path"),
2283 },
2284 }
2285 }
2286
2287 fn noop() -> FlowNode {
2288 FlowNode::Seq { children: vec![] }
2289 }
2290
2291 fn eq_cond(path: &str, lit: &str) -> Expr {
2292 Expr::Eq {
2293 lhs: Box::new(Expr::Path {
2294 at: path.parse().expect("literal test path"),
2295 }),
2296 rhs: Box::new(Expr::Lit {
2297 value: Value::String(lit.to_string()),
2298 }),
2299 }
2300 }
2301
2302 fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
2303 FlowNode::Branch {
2304 cond,
2305 then_: Box::new(then_),
2306 else_: Box::new(else_),
2307 }
2308 }
2309
2310 fn body_contract(values: &[&str]) -> VerdictContract {
2311 VerdictContract {
2312 channel: VerdictChannel::Body,
2313 values: values.iter().map(|v| v.to_string()).collect(),
2314 }
2315 }
2316
2317 fn part_contract(values: &[&str]) -> VerdictContract {
2318 VerdictContract {
2319 channel: VerdictChannel::Part,
2320 values: values.iter().map(|v| v.to_string()).collect(),
2321 }
2322 }
2323
2324 #[test]
2325 fn contract_with_correct_body_channel_and_value_compiles() {
2326 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2327 let flow = FlowNode::Seq {
2328 children: vec![
2329 step("gate", "$.verdict"),
2330 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2331 ],
2332 };
2333 let bp = minimal_bp(agent, flow);
2334 assert!(
2335 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2336 "a cond addressing the bare step output must match a channel: \"body\" contract"
2337 );
2338 }
2339
2340 #[test]
2341 fn contract_with_correct_part_channel_and_value_compiles() {
2342 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2343 let flow = FlowNode::Seq {
2344 children: vec![
2345 step("gate", "$.gate"),
2346 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2347 ],
2348 };
2349 let bp = minimal_bp(agent, flow);
2350 assert!(
2351 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2352 "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
2353 );
2354 }
2355
2356 #[test]
2357 fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
2358 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2362 let flow = FlowNode::Seq {
2363 children: vec![
2364 step("gate", "$.gate"),
2365 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
2366 ],
2367 };
2368 let bp = minimal_bp(agent, flow);
2369 match Compiler::new(registry_with_echo()).compile(&bp) {
2370 Err(CompileError::VerdictChannelMismatch {
2371 where_,
2372 agent,
2373 expected_channel,
2374 actual_shape,
2375 }) => {
2376 assert_eq!(agent, "gate");
2377 assert_eq!(expected_channel, "body");
2378 assert_eq!(actual_shape, "part");
2379 assert!(where_.contains("Branch cond"), "where_: {where_}");
2380 }
2381 Err(other) => {
2382 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2383 }
2384 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2385 }
2386 }
2387
2388 #[test]
2389 fn part_channel_contract_rejects_cond_addressing_bare_output() {
2390 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
2393 let flow = FlowNode::Seq {
2394 children: vec![
2395 step("gate", "$.verdict"),
2396 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2397 ],
2398 };
2399 let bp = minimal_bp(agent, flow);
2400 match Compiler::new(registry_with_echo()).compile(&bp) {
2401 Err(CompileError::VerdictChannelMismatch {
2402 agent,
2403 expected_channel,
2404 actual_shape,
2405 ..
2406 }) => {
2407 assert_eq!(agent, "gate");
2408 assert_eq!(expected_channel, "part");
2409 assert_eq!(actual_shape, "body");
2410 }
2411 Err(other) => {
2412 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
2413 }
2414 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
2415 }
2416 }
2417
2418 #[test]
2419 fn contract_rejects_lit_outside_declared_values() {
2420 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2421 let flow = FlowNode::Seq {
2422 children: vec![
2423 step("gate", "$.verdict"),
2424 branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
2425 ],
2426 };
2427 let bp = minimal_bp(agent, flow);
2428 match Compiler::new(registry_with_echo()).compile(&bp) {
2429 Err(CompileError::VerdictValueNotInContract {
2430 agent,
2431 value,
2432 values,
2433 ..
2434 }) => {
2435 assert_eq!(agent, "gate");
2436 assert_eq!(value, "UNKNOWN");
2437 assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
2438 }
2439 Err(other) => {
2440 panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
2441 }
2442 Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
2443 }
2444 }
2445
2446 #[test]
2447 fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
2448 let agent = gate_agent(None);
2449 let flow = FlowNode::Seq {
2450 children: vec![
2451 step("gate", "$.verdict"),
2452 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
2453 ],
2454 };
2455 let bp = minimal_bp(agent, flow);
2456 assert!(
2457 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2458 "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
2459 );
2460 }
2461
2462 #[test]
2463 fn in_expr_with_lit_haystack_members_compiles() {
2464 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
2465 let cond = Expr::In {
2466 needle: Box::new(Expr::Path {
2467 at: "$.verdict".parse().expect("literal test path"),
2468 }),
2469 haystack: Box::new(Expr::Lit {
2470 value: serde_json::json!(["PASS", "BLOCKED"]),
2471 }),
2472 };
2473 let flow = FlowNode::Seq {
2474 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
2475 };
2476 let bp = minimal_bp(agent, flow);
2477 assert!(
2478 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
2479 "an `In` haystack whose every Lit is a declared value must compile"
2480 );
2481 }
2482
2483 #[test]
2490 fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
2491 let agent = gate_agent(None);
2492 let flow = FlowNode::Seq {
2493 children: vec![
2494 step("gate", "$.verdict"),
2495 FlowNode::Loop {
2496 counter: Expr::Path {
2497 at: "$.n".parse().expect("literal test path"),
2498 },
2499 cond: eq_cond("$.verdict", "BLOCKED"),
2500 body: Box::new(step("gate", "$.verdict")),
2501 max: 3,
2502 },
2503 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
2504 ],
2505 };
2506 let bp = minimal_bp(agent, flow);
2507 let compiled = Compiler::new(registry_with_echo())
2508 .compile(&bp)
2509 .expect("a verdict-omitted Blueprint must compile unchanged");
2510 assert!(
2511 compiled.router.verdict_contracts.is_empty(),
2512 "no agent declared a verdict contract"
2513 );
2514 }
2515}