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, OperatorSlotResolver, 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::{BTreeMap, 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(
113 "spawner_hints.layers declares '{key}' but that layer has been removed: {reason}. \
114 Drop the key and route through the AgentSpec axis instead — declare the seat in \
115 `operators[]`, point the agent at it with `spec.operator_ref`, and pin its holder \
116 per launch with `operator_sid`"
117 )]
118 RemovedSpawnerHint {
119 key: String,
121 reason: String,
125 },
126 #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
130 UnresolvedMetaRef {
131 where_: String,
135 meta_ref: String,
137 defined: Vec<String>,
140 },
141 #[error("StepNaming collision: {0}")]
147 StepNamingCollision(#[from] StepNamingError),
148 #[error("invalid projection_placement: {0}")]
155 InvalidProjectionPlacement(#[from] ProjectionPlacementError),
156 #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
161 UnresolvedAuditAgent {
162 agent: String,
164 defined: Vec<String>,
167 },
168 #[error(
177 "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
178 addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
179 BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
180 )]
181 VerdictChannelMismatch {
182 where_: String,
185 agent: String,
187 expected_channel: String,
189 actual_shape: String,
192 },
193 #[error(
198 "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
199 values {values:?}"
200 )]
201 VerdictValueNotInContract {
202 where_: String,
205 agent: String,
208 value: String,
213 values: Vec<String>,
216 },
217 #[error(
229 "agent '{agent}' declares verdict value '{value}' but no downstream Branch/Loop \
230 cond references it (declared: {declared_values:?}, at step '{step_ref}') — either \
231 handle the value downstream or drop it from `verdict.values`"
232 )]
233 VerdictValueUnhandled {
234 agent: String,
237 value: String,
239 declared_values: Vec<String>,
242 step_ref: String,
247 },
248}
249
250pub const WORKER_BINDING_REQUIRED_MSG_PREFIX: &str =
259 "profile.worker_binding is required for this operator backend";
260
261pub fn removed_spawner_hint_reason(key: &str) -> Option<&'static str> {
274 match key {
275 "operator_delegate" => Some(
276 "the Blueprint-global Operator delegate axis was removed because it could not \
277 follow a seat handover (it resolved its destination from the launch-time \
278 `operator_backend_id`, never from `Run.current`) and could not carry an \
279 agent's `system_prompt` (it had no per-agent spawner, so it passed `system: \
280 None` and never baked one for `/v1/worker/prompt`)",
281 ),
282 _ => None,
283 }
284}
285
286impl From<&CompileError> for mlua_swarm_diag::Diagnostic {
304 fn from(err: &CompileError) -> Self {
305 use mlua_swarm_diag::{
306 Applicability, DiagElement, DiagLevel, DiagSpan, DiagStage, Diagnostic, DocsRef,
307 Suggestion,
308 };
309 let base = |kind: &'static str| {
310 Diagnostic::new(
311 kind,
312 DiagStage::CompileLint,
313 DiagLevel::Error,
314 err.to_string(),
315 )
316 };
317 let agent_span = |name: &str| DiagSpan {
318 element: DiagElement::Agent {
319 name: name.to_string(),
320 },
321 json_path: Some(format!("$.agents[?(@.name=='{name}')]")),
322 };
323 match err {
324 CompileError::BoundAgent(_) => base("bound-agent-resolution"),
325 CompileError::UnknownKind(_) => base("unknown-agent-kind").with_help(
326 "register a SpawnerFactory for this kind, or disable strategy.strict_kind",
327 ),
328 CompileError::InvalidSpec { name, msg }
329 if msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX) =>
330 {
331 Diagnostic::new(
332 "worker-binding-missing",
333 DiagStage::CompileLint,
334 DiagLevel::Error,
335 format!(
336 "operator agent '{name}' has no explicit Runner or legacy \
337 `profile.worker_binding`"
338 ),
339 )
340 .with_note(msg.clone())
341 .with_suggestion(Suggestion {
342 msg: "add an explicit Runner (or legacy profile.worker_binding)".into(),
343 patch: "runner = { backend = \"ws_operator\", variant = \"claude\", \
344 tools = {} }"
345 .into(),
346 applicability: Applicability::HasPlaceholders,
347 })
348 .with_docs_ref(DocsRef {
349 uri: "mse://guides/bp-dsl-templates",
350 anchor: None,
351 })
352 .with_span(agent_span(name))
353 }
354 CompileError::InvalidSpec { name, .. } => {
355 base("invalid-agent-spec").with_span(agent_span(name))
356 }
357 CompileError::UnresolvedRef(ref_) => base("unresolved-agent-ref").with_span(DiagSpan {
358 element: DiagElement::Step { ref_: ref_.clone() },
359 json_path: None,
360 }),
361 CompileError::DuplicateAgent(name) => {
362 base("duplicate-agent-name").with_span(agent_span(name))
363 }
364 CompileError::UnresolvedOperatorRef { agent, defined, .. } => {
365 base("unresolved-operator-ref")
366 .with_note(format!("declared OperatorDef names: {defined:?}"))
367 .with_span(agent_span(agent))
368 }
369 CompileError::RemovedSpawnerHint { key, .. } => base("removed-spawner-hint")
370 .with_note(
371 "`service::linker::link` skips hint keys a deployment does not install, \
372 so leaving this key in place would drop the capability silently rather \
373 than report it"
374 .to_string(),
375 )
376 .with_help(
377 "route the spawn through the AgentSpec axis: the seat is declared once in \
378 `operators[]`, each agent that should reach an Operator selects it with \
379 `spec.operator_ref`, and the launch names who holds the seat for that run \
380 via `operator_sid` (which a later handover can move without recompiling)",
381 )
382 .with_suggestion(mlua_swarm_diag::removed_spawner_hint_suggestion())
388 .with_docs_ref(DocsRef {
389 uri: "mse://guides/blueprint-authoring",
390 anchor: Some("removed-spawner-hint-layers"),
391 })
392 .with_span(DiagSpan {
393 element: DiagElement::BlueprintRoot,
394 json_path: Some(format!("$.spawner_hints.layers[?(@=='{key}')]")),
395 }),
396 CompileError::UnresolvedMetaRef { defined, .. } => base("unresolved-meta-ref")
397 .with_note(format!("declared MetaDef names: {defined:?}")),
398 CompileError::StepNamingCollision(_) => base("step-naming-collision"),
399 CompileError::InvalidProjectionPlacement(_) => base("invalid-projection-placement")
400 .with_span(DiagSpan {
401 element: DiagElement::BlueprintRoot,
402 json_path: Some("$.projection_placement".into()),
403 }),
404 CompileError::UnresolvedAuditAgent { defined, .. } => base("unresolved-audit-agent")
405 .with_note(format!("declared AgentDef names: {defined:?}"))
406 .with_span(DiagSpan {
407 element: DiagElement::BlueprintRoot,
408 json_path: Some("$.audits".into()),
409 }),
410 CompileError::VerdictChannelMismatch { agent, .. } => base("verdict-channel-mismatch")
411 .with_help(
412 "see the \"Returning verdicts to drive BP flow\" guide's Pattern A \
413 (channel: \"body\") / Pattern B (channel: \"part\")",
414 )
415 .with_docs_ref(DocsRef {
416 uri: "mse://guides/blueprint-authoring",
417 anchor: None,
418 })
419 .with_span(agent_span(agent)),
420 CompileError::VerdictValueNotInContract { agent, .. } => {
421 base("verdict-value-not-in-contract")
422 .with_suggestion(Suggestion {
428 msg: "align the cond literal with the agent's declared verdict \
429 contract"
430 .into(),
431 patch: "either add the cond's literal to `agents[N].verdict.values`, \
432 or change the cond to a value that is already declared"
433 .into(),
434 applicability: Applicability::MaybeIncorrect,
435 })
436 .with_docs_ref(DocsRef {
437 uri: "mse://guides/blueprint-authoring",
438 anchor: None,
439 })
440 .with_span(agent_span(agent))
441 }
442 CompileError::VerdictValueUnhandled {
443 agent,
444 declared_values,
445 ..
446 } => base("verdict-value-unhandled")
447 .with_note(format!("declared verdict.values: {declared_values:?}"))
448 .with_help(
449 "either handle the value in a downstream Branch/Loop cond, or drop it \
450 from verdict.values",
451 )
452 .with_span(agent_span(agent)),
453 }
454 }
455}
456
457pub trait SpawnerFactory: Send + Sync {
469 fn build(
472 &self,
473 agent_def: &AgentDef,
474 hint: Option<&Value>,
475 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
476}
477
478pub trait SpawnerFactoryKind: SpawnerFactory {
494 const KIND: AgentKind;
497 type Worker: crate::worker::Worker;
504}
505
506#[derive(Clone)]
509pub struct SpawnerRegistry {
510 factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
511}
512
513impl SpawnerRegistry {
514 pub fn new() -> Self {
516 Self {
517 factories: HashMap::new(),
518 }
519 }
520 pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
529 let f: Arc<dyn SpawnerFactory> = factory;
530 self.factories.insert(F::KIND, f);
531 self
532 }
533}
534
535impl Default for SpawnerRegistry {
536 fn default() -> Self {
537 Self::new()
538 }
539}
540
541pub struct Compiler {
548 registry: SpawnerRegistry,
549 default_spawner: Option<Arc<dyn SpawnerAdapter>>,
550}
551
552pub struct CompiledBlueprint {
556 pub router: Arc<CompiledAgentTable>,
558 pub flow: FlowNode,
560 pub metadata: BlueprintMetadata,
562 pub step_naming: Arc<StepNaming>,
567 pub projection_placement: Arc<ProjectionPlacement>,
573}
574
575fn project_bound_agent_for_legacy_factories(bound: &BoundAgent) -> AgentDef {
576 let mut agent = bound.agent.clone();
577 match &bound.runner {
578 Some(Runner::WsOperator { variant, tools })
579 | Some(Runner::WsClaudeCode { variant, tools }) => {
580 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
581 profile.worker_binding = Some(variant.clone());
582 profile.tools = tools.clone();
583 }
584 Some(Runner::AgentBlockInProcess { tools }) => {
585 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
586 profile.worker_binding = None;
587 profile.tools = tools.clone();
588 }
589 Some(Runner::Subprocess { .. }) => {}
594 None => {}
595 }
596 let meta = agent.meta.get_or_insert_with(Default::default);
597 meta.context_policy = bound.context_policy.clone();
598 agent
599}
600
601pub(crate) fn materialize_bound_blueprint(
604 bp: &Blueprint,
605 bound_agents: &[BoundAgent],
606) -> Blueprint {
607 let mut effective = bp.clone();
608 effective.agents = bound_agents
609 .iter()
610 .map(project_bound_agent_for_legacy_factories)
611 .collect();
612 effective.default_context_policy = None;
615 effective
616}
617
618impl Compiler {
619 pub fn new(registry: SpawnerRegistry) -> Self {
623 Self {
624 registry,
625 default_spawner: None,
626 }
627 }
628
629 pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
633 self.default_spawner = Some(sp);
634 self
635 }
636
637 pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
642 let bound_agents = resolve_bound_agents(bp)?;
643 self.compile_bound(bp, &bound_agents)
644 }
645
646 pub fn compile_bound(
659 &self,
660 bp: &Blueprint,
661 bound_agents: &[BoundAgent],
662 ) -> Result<CompiledBlueprint, CompileError> {
663 let effective = materialize_bound_blueprint(bp, bound_agents);
664 self.compile_resolved(&effective)
665 }
666
667 fn compile_resolved(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
668 let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
669 let mut seen: HashMap<String, ()> = HashMap::new();
670 let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
676
677 for key in &bp.spawner_hints.layers {
694 if let Some(reason) = removed_spawner_hint_reason(key) {
695 return Err(CompileError::RemovedSpawnerHint {
696 key: key.clone(),
697 reason: reason.to_string(),
698 });
699 }
700 }
701
702 let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
708 for ad in &bp.agents {
709 if !matches!(ad.kind, AgentKind::Operator) {
710 continue;
711 }
712 let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
713 if let Some(op_ref) = op_ref {
714 if !defined.iter().any(|n| n == op_ref) {
715 return Err(CompileError::UnresolvedOperatorRef {
716 agent: ad.name.clone(),
717 op_ref: op_ref.to_string(),
718 defined: defined.clone(),
719 });
720 }
721 }
722 }
724
725 let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
729 for ad in &bp.agents {
730 let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
731 if let Some(meta_ref) = meta_ref {
732 if !metas_defined.iter().any(|n| n == meta_ref) {
733 return Err(CompileError::UnresolvedMetaRef {
734 where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
735 meta_ref: meta_ref.clone(),
736 defined: metas_defined.clone(),
737 });
738 }
739 }
740 }
741 let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
747 collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
748 for (where_, meta_ref) in static_step_meta_refs {
749 if !metas_defined.iter().any(|n| n == &meta_ref) {
750 return Err(CompileError::UnresolvedMetaRef {
751 where_,
752 meta_ref,
753 defined: metas_defined.clone(),
754 });
755 }
756 }
757
758 let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
763 for audit in &bp.audits {
764 if !agents_defined.iter().any(|n| n == &audit.agent) {
765 return Err(CompileError::UnresolvedAuditAgent {
766 agent: audit.agent.clone(),
767 defined: agents_defined.clone(),
768 });
769 }
770 }
771
772 for ad in &bp.agents {
773 if seen.contains_key(&ad.name) {
774 return Err(CompileError::DuplicateAgent(ad.name.clone()));
775 }
776 seen.insert(ad.name.clone(), ());
777
778 if let Some(contract) = &ad.verdict {
784 verdict_contracts.insert(ad.name.clone(), contract.clone());
785 }
786
787 let factory = match self.registry.factories.get(&ad.kind) {
788 Some(f) => f.clone(),
789 None => {
790 if bp.strategy.strict_kind {
791 return Err(CompileError::UnknownKind(ad.kind.clone()));
792 } else {
793 tracing::warn!(
794 agent = %ad.name,
795 kind = ?ad.kind,
796 "no spawner factory registered for agent kind; \
797 dropping agent from routing table (strict_kind=false)"
798 );
799 continue;
800 }
801 }
802 };
803 let hint = bp.hints.per_agent.get(&ad.name);
804 let subprocess_hint = if ad.kind == AgentKind::Subprocess {
816 resolve_subprocess_template_hint(bp, ad)?
817 } else {
818 None
819 };
820 let spawner = factory.build(ad, subprocess_hint.as_ref().or(hint))?;
821 routes.insert(ad.name.clone(), spawner);
822 }
823
824 let unhandled_gates = resolve_unhandled_verdict_gates(bp);
844 verify_verdict_conds(&bp.flow, &verdict_contracts, &unhandled_gates)?;
845
846 if bp.strategy.strict_refs {
847 verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
848 }
849
850 let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
866 for warning in &step_naming_warnings {
867 tracing::warn!(
868 name = %warning.name,
869 first_step_ref = %warning.first_step_ref,
870 second_step_ref = %warning.second_step_ref,
871 "StepNaming: undeclared steps' canonical/alias names collide; \
872 the step whose own ref matches the name keeps it (data-plane priority)"
873 );
874 }
875
876 let projection_placement =
884 ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
885
886 let router = Arc::new(CompiledAgentTable {
887 routes,
888 default: self.default_spawner.clone(),
889 verdict_contracts,
890 });
891 Ok(CompiledBlueprint {
892 router,
893 flow: bp.flow.clone(),
894 metadata: bp.metadata.clone(),
895 step_naming: Arc::new(step_naming),
896 projection_placement: Arc::new(projection_placement),
897 })
898 }
899}
900
901fn verify_refs(
904 node: &FlowNode,
905 routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
906 has_default: bool,
907) -> Result<(), CompileError> {
908 let mut refs: Vec<String> = Vec::new();
909 collect_refs(node, &mut refs);
910 for r in refs {
911 if !routes.contains_key(&r) && !has_default {
912 return Err(CompileError::UnresolvedRef(r));
913 }
914 }
915 Ok(())
916}
917
918fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
919 match node {
920 FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
921 FlowNode::Seq { children } => {
922 for c in children {
923 collect_refs(c, out);
924 }
925 }
926 FlowNode::Branch { then_, else_, .. } => {
927 collect_refs(then_, out);
928 collect_refs(else_, out);
929 }
930 FlowNode::Fanout { body, .. } => collect_refs(body, out),
931 FlowNode::Loop { body, .. } => collect_refs(body, out),
932 FlowNode::Try { body, catch, .. } => {
933 collect_refs(body, out);
934 collect_refs(catch, out);
935 }
936 FlowNode::Let { .. } => {} }
938}
939
940fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
948 match node {
949 FlowNode::Step { ref_, in_, .. } => {
950 if let Expr::Lit { value } = in_ {
951 if let Some(meta_ref) = static_step_meta_ref(value) {
952 out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
953 }
954 }
955 }
956 FlowNode::Seq { children } => {
957 for c in children {
958 collect_step_meta_refs(c, out);
959 }
960 }
961 FlowNode::Branch { then_, else_, .. } => {
962 collect_step_meta_refs(then_, out);
963 collect_step_meta_refs(else_, out);
964 }
965 FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
966 FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
967 FlowNode::Try { body, catch, .. } => {
968 collect_step_meta_refs(body, out);
969 collect_step_meta_refs(catch, out);
970 }
971 FlowNode::Let { .. } => {} }
973}
974
975fn static_step_meta_ref(value: &Value) -> Option<String> {
982 value
983 .as_object()?
984 .get("$step_meta")?
985 .as_object()?
986 .get("ref")?
987 .as_str()
988 .map(str::to_string)
989}
990
991const UNHANDLED_VERDICT_LINT_KIND: &str = "verdict-value-unhandled";
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1003enum UnhandledVerdictGate {
1004 Deny,
1006 Warn,
1008 Silence,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq)]
1021struct UnhandledVerdictGates {
1022 per_agent: HashMap<String, UnhandledVerdictGate>,
1024 blueprint: UnhandledVerdictGate,
1026}
1027
1028impl UnhandledVerdictGates {
1029 fn for_agent(&self, agent: &str) -> UnhandledVerdictGate {
1032 self.per_agent.get(agent).copied().unwrap_or(self.blueprint)
1033 }
1034
1035 fn all_silent(&self) -> bool {
1038 self.blueprint == UnhandledVerdictGate::Silence
1039 && self
1040 .per_agent
1041 .values()
1042 .all(|g| *g == UnhandledVerdictGate::Silence)
1043 }
1044}
1045
1046fn resolve_unhandled_verdict_gates(bp: &Blueprint) -> UnhandledVerdictGates {
1054 let strict = bp.metadata.strict_verdict_handling.unwrap_or(false);
1055 let blueprint = resolve_unhandled_verdict_gate(&bp.metadata);
1056 let per_agent = bp
1057 .agents
1058 .iter()
1059 .filter_map(|ad| {
1060 let declared = declared_unhandled_verdict_setting(&ad.lints)?;
1061 Some((
1062 ad.name.clone(),
1063 unhandled_verdict_gate(strict, Some(declared)),
1064 ))
1065 })
1066 .collect();
1067 UnhandledVerdictGates {
1068 per_agent,
1069 blueprint,
1070 }
1071}
1072
1073fn resolve_unhandled_verdict_gate(metadata: &BlueprintMetadata) -> UnhandledVerdictGate {
1076 unhandled_verdict_gate(
1077 metadata.strict_verdict_handling.unwrap_or(false),
1078 declared_unhandled_verdict_setting(&metadata.lints),
1079 )
1080}
1081
1082fn declared_unhandled_verdict_setting(
1092 lints: &Option<BTreeMap<String, mlua_swarm_schema::LintSetting>>,
1093) -> Option<mlua_swarm_diag::LintSetting> {
1094 use mlua_swarm_diag::{lint_decl, LintConfig};
1095
1096 let cfg = LintConfig::from_pairs(
1097 lints
1098 .as_ref()?
1099 .iter()
1100 .map(|(key, setting)| (key.clone(), diag_lint_setting(*setting))),
1101 );
1102 cfg.setting_for(lint_decl(UNHANDLED_VERDICT_LINT_KIND)?)
1103}
1104
1105fn unhandled_verdict_gate(
1113 strict: bool,
1114 declared: Option<mlua_swarm_diag::LintSetting>,
1115) -> UnhandledVerdictGate {
1116 use mlua_swarm_diag::LintSetting;
1117
1118 match declared {
1119 _ if strict => UnhandledVerdictGate::Deny,
1120 Some(LintSetting::Deny) => UnhandledVerdictGate::Deny,
1121 Some(LintSetting::Allow) => UnhandledVerdictGate::Silence,
1122 Some(LintSetting::Warn) | None => UnhandledVerdictGate::Warn,
1123 }
1124}
1125
1126fn diag_lint_setting(setting: mlua_swarm_schema::LintSetting) -> mlua_swarm_diag::LintSetting {
1131 match setting {
1132 mlua_swarm_schema::LintSetting::Allow => mlua_swarm_diag::LintSetting::Allow,
1133 mlua_swarm_schema::LintSetting::Warn => mlua_swarm_diag::LintSetting::Warn,
1134 mlua_swarm_schema::LintSetting::Deny => mlua_swarm_diag::LintSetting::Deny,
1135 }
1136}
1137
1138fn verify_verdict_conds(
1148 flow: &FlowNode,
1149 verdict_contracts: &HashMap<String, VerdictContract>,
1150 unhandled_gates: &UnhandledVerdictGates,
1151) -> Result<(), CompileError> {
1152 let mut step_outputs: HashMap<String, String> = HashMap::new();
1153 let mut step_agents: HashMap<String, String> = HashMap::new();
1154 collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1155
1156 let mut errors: Vec<CompileError> = Vec::new();
1157 let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1158 collect_verdict_conds(
1159 flow,
1160 &step_outputs,
1161 verdict_contracts,
1162 &mut referenced_values,
1163 &mut errors,
1164 );
1165 check_unhandled_verdict_values(
1166 verdict_contracts,
1167 &referenced_values,
1168 &step_agents,
1169 unhandled_gates,
1170 &mut errors,
1171 );
1172 match errors.into_iter().next() {
1173 Some(e) => Err(e),
1174 None => Ok(()),
1175 }
1176}
1177
1178fn collect_step_outputs_and_agents(
1193 node: &FlowNode,
1194 out: &mut HashMap<String, String>,
1195 step_agents: &mut HashMap<String, String>,
1196) {
1197 match node {
1198 FlowNode::Step {
1199 ref_,
1200 out: out_expr,
1201 ..
1202 } => {
1203 if let Expr::Path { at } = out_expr {
1204 out.insert(at.to_string(), ref_.clone());
1205 }
1206 step_agents
1207 .entry(ref_.clone())
1208 .or_insert_with(|| ref_.clone());
1209 }
1210 FlowNode::Seq { children } => {
1211 for c in children {
1212 collect_step_outputs_and_agents(c, out, step_agents);
1213 }
1214 }
1215 FlowNode::Branch { then_, else_, .. } => {
1216 collect_step_outputs_and_agents(then_, out, step_agents);
1217 collect_step_outputs_and_agents(else_, out, step_agents);
1218 }
1219 FlowNode::Fanout { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1220 FlowNode::Loop { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1221 FlowNode::Try { body, catch, .. } => {
1222 collect_step_outputs_and_agents(body, out, step_agents);
1223 collect_step_outputs_and_agents(catch, out, step_agents);
1224 }
1225 FlowNode::Let { .. } => {} }
1227}
1228
1229fn collect_verdict_conds(
1234 node: &FlowNode,
1235 step_outputs: &HashMap<String, String>,
1236 verdict_contracts: &HashMap<String, VerdictContract>,
1237 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1238 errors: &mut Vec<CompileError>,
1239) {
1240 match node {
1241 FlowNode::Branch { cond, then_, else_ } => {
1242 lint_cond_expr(
1243 cond,
1244 "Branch cond",
1245 step_outputs,
1246 verdict_contracts,
1247 referenced_values,
1248 errors,
1249 );
1250 collect_verdict_conds(
1251 then_,
1252 step_outputs,
1253 verdict_contracts,
1254 referenced_values,
1255 errors,
1256 );
1257 collect_verdict_conds(
1258 else_,
1259 step_outputs,
1260 verdict_contracts,
1261 referenced_values,
1262 errors,
1263 );
1264 }
1265 FlowNode::Loop { cond, body, .. } => {
1266 lint_cond_expr(
1267 cond,
1268 "Loop cond",
1269 step_outputs,
1270 verdict_contracts,
1271 referenced_values,
1272 errors,
1273 );
1274 collect_verdict_conds(
1275 body,
1276 step_outputs,
1277 verdict_contracts,
1278 referenced_values,
1279 errors,
1280 );
1281 }
1282 FlowNode::Seq { children } => {
1283 for c in children {
1284 collect_verdict_conds(
1285 c,
1286 step_outputs,
1287 verdict_contracts,
1288 referenced_values,
1289 errors,
1290 );
1291 }
1292 }
1293 FlowNode::Fanout { body, .. } => collect_verdict_conds(
1294 body,
1295 step_outputs,
1296 verdict_contracts,
1297 referenced_values,
1298 errors,
1299 ),
1300 FlowNode::Try { body, catch, .. } => {
1301 collect_verdict_conds(
1302 body,
1303 step_outputs,
1304 verdict_contracts,
1305 referenced_values,
1306 errors,
1307 );
1308 collect_verdict_conds(
1309 catch,
1310 step_outputs,
1311 verdict_contracts,
1312 referenced_values,
1313 errors,
1314 );
1315 }
1316 FlowNode::Step { .. } | FlowNode::Let { .. } => {}
1317 }
1318}
1319
1320fn lint_cond_expr(
1329 expr: &Expr,
1330 where_: &str,
1331 step_outputs: &HashMap<String, String>,
1332 verdict_contracts: &HashMap<String, VerdictContract>,
1333 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1334 errors: &mut Vec<CompileError>,
1335) {
1336 match expr {
1337 Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
1338 if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
1339 resolve_and_check(
1340 path,
1341 &[lit],
1342 where_,
1343 step_outputs,
1344 verdict_contracts,
1345 referenced_values,
1346 errors,
1347 );
1348 }
1349 }
1350 Expr::In { needle, haystack } => {
1351 if let (
1352 Expr::Path { at },
1353 Expr::Lit {
1354 value: Value::Array(items),
1355 },
1356 ) = (needle.as_ref(), haystack.as_ref())
1357 {
1358 let lits: Vec<&Value> = items.iter().collect();
1359 resolve_and_check(
1360 at,
1361 &lits,
1362 where_,
1363 step_outputs,
1364 verdict_contracts,
1365 referenced_values,
1366 errors,
1367 );
1368 }
1369 }
1370 Expr::And { args } | Expr::Or { args } => {
1371 for a in args {
1372 lint_cond_expr(
1373 a,
1374 where_,
1375 step_outputs,
1376 verdict_contracts,
1377 referenced_values,
1378 errors,
1379 );
1380 }
1381 }
1382 Expr::Not { arg } => lint_cond_expr(
1383 arg,
1384 where_,
1385 step_outputs,
1386 verdict_contracts,
1387 referenced_values,
1388 errors,
1389 ),
1390 _ => {}
1391 }
1392}
1393
1394fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
1400 match (lhs, rhs) {
1401 (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
1402 (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
1403 _ => None,
1404 }
1405}
1406
1407fn resolve_and_check(
1422 path: &Path,
1423 lits: &[&Value],
1424 where_: &str,
1425 step_outputs: &HashMap<String, String>,
1426 verdict_contracts: &HashMap<String, VerdictContract>,
1427 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1428 errors: &mut Vec<CompileError>,
1429) {
1430 let path_str = path.to_string();
1431 let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
1432 (agent, "body")
1433 } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
1434 match step_outputs.get(stripped) {
1435 Some(agent) => (agent, "part"),
1436 None => return,
1437 }
1438 } else {
1439 return;
1440 };
1441
1442 let Some(contract) = verdict_contracts.get(agent) else {
1443 tracing::warn!(
1444 agent = %agent,
1445 where_ = %where_,
1446 "cond references agent output but no verdict contract declared"
1447 );
1448 return;
1449 };
1450
1451 let expected_channel = match contract.channel {
1452 VerdictChannel::Body => "body",
1453 VerdictChannel::Part => "part",
1454 };
1455 if expected_channel != actual_shape {
1456 errors.push(CompileError::VerdictChannelMismatch {
1457 where_: where_.to_string(),
1458 agent: agent.clone(),
1459 expected_channel: expected_channel.to_string(),
1460 actual_shape: actual_shape.to_string(),
1461 });
1462 return;
1463 }
1464
1465 for lit in lits {
1466 let value_str = lit
1467 .as_str()
1468 .map(str::to_string)
1469 .unwrap_or_else(|| lit.to_string());
1470 if !contract.values.iter().any(|v| v == &value_str) {
1471 errors.push(CompileError::VerdictValueNotInContract {
1472 where_: where_.to_string(),
1473 agent: agent.clone(),
1474 value: value_str.clone(),
1475 values: contract.values.clone(),
1476 });
1477 }
1478 referenced_values
1485 .entry(agent.clone())
1486 .or_default()
1487 .insert(value_str);
1488 }
1489}
1490
1491fn check_unhandled_verdict_values(
1515 verdict_contracts: &HashMap<String, VerdictContract>,
1516 referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1517 step_agents: &HashMap<String, String>,
1518 unhandled_gates: &UnhandledVerdictGates,
1519 errors: &mut Vec<CompileError>,
1520) {
1521 if unhandled_gates.all_silent() {
1522 return;
1523 }
1524 for finding in fold_unhandled_verdict_values(verdict_contracts, referenced_values, step_agents)
1525 {
1526 let gate = unhandled_gates.for_agent(&finding.agent);
1527 match gate {
1528 UnhandledVerdictGate::Deny => errors.push(CompileError::VerdictValueUnhandled {
1529 agent: finding.agent,
1530 value: finding.value,
1531 declared_values: finding.declared_values,
1532 step_ref: finding.step_ref,
1533 }),
1534 UnhandledVerdictGate::Warn => tracing::warn!(
1535 agent = %finding.agent,
1536 value = %finding.value,
1537 step_ref = %finding.step_ref,
1538 "declared verdict value has no downstream cond handler; \
1539 declare `metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}` \
1540 to reject at compile"
1541 ),
1542 UnhandledVerdictGate::Silence => {}
1546 }
1547 }
1548}
1549
1550#[derive(Debug, Clone, PartialEq, Eq)]
1561pub struct UnhandledVerdictValue {
1562 pub agent: String,
1564 pub value: String,
1566 pub declared_values: Vec<String>,
1568 pub step_ref: String,
1570}
1571
1572pub fn unhandled_verdict_values(
1589 flow: &FlowNode,
1590 verdict_contracts: &HashMap<String, VerdictContract>,
1591) -> Vec<UnhandledVerdictValue> {
1592 let mut step_outputs: HashMap<String, String> = HashMap::new();
1593 let mut step_agents: HashMap<String, String> = HashMap::new();
1594 collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1595
1596 let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1597 let mut discarded_errors: Vec<CompileError> = Vec::new();
1598 collect_verdict_conds(
1599 flow,
1600 &step_outputs,
1601 verdict_contracts,
1602 &mut referenced_values,
1603 &mut discarded_errors,
1604 );
1605 fold_unhandled_verdict_values(verdict_contracts, &referenced_values, &step_agents)
1606}
1607
1608#[derive(Debug, Clone, PartialEq, Eq)]
1622pub struct AgentContractUnread {
1623 pub agent: String,
1625 pub declared_values: Vec<String>,
1627 pub step_ref: String,
1629}
1630
1631pub fn agents_with_all_verdict_values_unread(
1642 flow: &FlowNode,
1643 verdict_contracts: &HashMap<String, VerdictContract>,
1644) -> Vec<AgentContractUnread> {
1645 let per_value = unhandled_verdict_values(flow, verdict_contracts);
1646 let mut unread_counts: HashMap<String, usize> = HashMap::new();
1647 for finding in &per_value {
1648 *unread_counts.entry(finding.agent.clone()).or_default() += 1;
1649 }
1650 let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1651 agents.sort();
1652 let mut out = Vec::new();
1653 for agent in agents {
1654 let contract = &verdict_contracts[agent];
1655 let declared = contract.values.len();
1656 if declared == 0 {
1657 continue;
1658 }
1659 let unread = unread_counts.get(agent).copied().unwrap_or(0);
1660 if unread != declared {
1661 continue;
1662 }
1663 let step_ref = per_value
1667 .iter()
1668 .find(|f| &f.agent == agent)
1669 .map(|f| f.step_ref.clone())
1670 .unwrap_or_else(|| agent.clone());
1671 out.push(AgentContractUnread {
1672 agent: agent.clone(),
1673 declared_values: contract.values.clone(),
1674 step_ref,
1675 });
1676 }
1677 out
1678}
1679
1680fn fold_unhandled_verdict_values(
1691 verdict_contracts: &HashMap<String, VerdictContract>,
1692 referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1693 step_agents: &HashMap<String, String>,
1694) -> Vec<UnhandledVerdictValue> {
1695 let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1696 agents.sort();
1697 let mut findings = Vec::new();
1698 for agent in agents {
1699 let contract = &verdict_contracts[agent];
1700 let referenced = referenced_values.get(agent);
1701 let step_ref = step_agents
1702 .get(agent)
1703 .cloned()
1704 .unwrap_or_else(|| agent.clone());
1705 for value in &contract.values {
1706 let handled = referenced.map(|set| set.contains(value)).unwrap_or(false);
1707 if handled {
1708 continue;
1709 }
1710 findings.push(UnhandledVerdictValue {
1711 agent: agent.clone(),
1712 value: value.clone(),
1713 declared_values: contract.values.clone(),
1714 step_ref: step_ref.clone(),
1715 });
1716 }
1717 }
1718 findings
1719}
1720
1721pub struct CompiledAgentTable {
1734 pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
1735 pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
1736 pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
1740}
1741
1742impl CompiledAgentTable {
1743 pub fn has_route(&self, agent: &str) -> bool {
1746 self.routes.contains_key(agent)
1747 }
1748 pub fn routed_agents(&self) -> Vec<String> {
1750 self.routes.keys().cloned().collect()
1751 }
1752 pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
1756 self.verdict_contracts.get(agent)
1757 }
1758}
1759
1760#[async_trait]
1761impl SpawnerAdapter for CompiledAgentTable {
1762 async fn spawn(
1763 &self,
1764 engine: &Engine,
1765 ctx: &Ctx,
1766 task_id: StepId,
1767 attempt: u32,
1768 token: CapToken,
1769 ) -> Result<Box<dyn Worker>, SpawnError> {
1770 let sp = self
1771 .routes
1772 .get(&ctx.agent)
1773 .cloned()
1774 .or_else(|| self.default.clone())
1775 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
1776 sp.spawn(engine, ctx, task_id, attempt, token).await
1777 }
1778}
1779
1780pub struct SubprocessProcessSpawnerFactory;
1811
1812impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
1813 const KIND: AgentKind = AgentKind::Subprocess;
1814 type Worker = crate::worker::process_spawner::ProcessWorker;
1815}
1816
1817pub const SUBPROCESS_TEMPLATE_HINT_KEY: &str = "subprocess_template";
1820pub const SUBPROCESS_OVERRIDES_HINT_KEY: &str = "subprocess_overrides";
1822
1823fn validate_embed_placeholders(s: &str, where_: &str) -> Result<(), String> {
1837 let mut rest = s;
1838 while let Some(start) = rest.find('{') {
1839 let after = &rest[start + 1..];
1840 let Some(end) = after.find('}') else {
1841 break;
1842 };
1843 let token = &after[..end];
1844 let is_candidate =
1845 !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase() || c == '_');
1846 if is_candidate {
1847 if !crate::worker::process_spawner::EMBED_PLACEHOLDERS.contains(&token) {
1848 return Err(format!(
1849 "unknown placeholder '{{{token}}}' in {where_}; closed set is \
1850 {{system, system_file, prompt, model, tools_csv, work_dir, task_id, attempt}}"
1851 ));
1852 }
1853 rest = &after[end + 1..];
1854 } else {
1855 rest = after;
1860 }
1861 }
1862 Ok(())
1863}
1864
1865fn resolve_subprocess_template_hint(
1871 bp: &Blueprint,
1872 ad: &AgentDef,
1873) -> Result<Option<Value>, CompileError> {
1874 let invalid = |msg: String| CompileError::InvalidSpec {
1875 name: ad.name.clone(),
1876 msg,
1877 };
1878 let runner = mlua_swarm_schema::resolve_runner(bp, ad).map_err(|e| invalid(e.to_string()))?;
1879 let Some(Runner::Subprocess {
1880 template,
1881 overrides,
1882 }) = runner
1883 else {
1884 return Ok(None);
1885 };
1886 let def = bp
1887 .subprocesses
1888 .iter()
1889 .find(|d| d.name == template)
1890 .ok_or_else(|| {
1891 let mut names: Vec<&str> = bp.subprocesses.iter().map(|d| d.name.as_str()).collect();
1892 names.sort_unstable();
1893 invalid(format!(
1894 "Runner::Subprocess template '{template}' not found in \
1895 Blueprint.subprocesses (defined: [{}])",
1896 names.join(", ")
1897 ))
1898 })?;
1899 Ok(Some(serde_json::json!({
1900 SUBPROCESS_TEMPLATE_HINT_KEY: def,
1901 SUBPROCESS_OVERRIDES_HINT_KEY: overrides,
1902 })))
1903}
1904
1905impl SubprocessProcessSpawnerFactory {
1906 fn build_embed(
1911 agent_def: &AgentDef,
1912 template: &Value,
1913 overrides: Option<&Value>,
1914 ) -> Result<ProcessSpawner, CompileError> {
1915 use crate::worker::process_spawner::EmbedTemplate;
1916 use mlua_swarm_schema::{SubprocessDef, SubprocessOverrides};
1917
1918 let agent_name = &agent_def.name;
1919 let invalid = |msg: String| CompileError::InvalidSpec {
1920 name: agent_name.to_string(),
1921 msg,
1922 };
1923 let def: SubprocessDef = serde_json::from_value(template.clone())
1924 .map_err(|e| invalid(format!("subprocess_template hint: {e}")))?;
1925 let overrides: SubprocessOverrides = match overrides {
1926 Some(v) => serde_json::from_value(v.clone())
1927 .map_err(|e| invalid(format!("subprocess_overrides hint: {e}")))?,
1928 None => SubprocessOverrides::default(),
1929 };
1930
1931 if def.argv.is_empty() {
1932 return Err(invalid(format!(
1933 "SubprocessDef '{}': argv must not be empty",
1934 def.name
1935 )));
1936 }
1937 for (i, a) in def.argv.iter().enumerate() {
1939 validate_embed_placeholders(a, &format!("argv[{i}]")).map_err(&invalid)?;
1940 }
1941 if let Some(stdin) = &def.stdin {
1942 validate_embed_placeholders(stdin, "stdin").map_err(&invalid)?;
1943 }
1944 for (k, v) in &def.env {
1945 validate_embed_placeholders(v, &format!("env['{k}']")).map_err(&invalid)?;
1946 }
1947 if let Some(cwd) = &def.cwd {
1948 validate_embed_placeholders(cwd, "cwd").map_err(&invalid)?;
1949 }
1950 let stream_mode = match def.stream_mode.as_deref() {
1951 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
1952 Some("sse_events") => Some(StreamMode::SseEvents),
1953 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
1954 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
1955 None => None,
1956 };
1957 if def.timeout_ms == Some(0) {
1958 return Err(invalid(format!(
1959 "SubprocessDef '{}': timeout_ms must be greater than 0 \
1960 (omit the field for no per-attempt limit)",
1961 def.name
1962 )));
1963 }
1964 if let Some(output) = &def.output {
1965 if stream_mode.is_some() {
1966 return Err(invalid(format!(
1967 "SubprocessDef '{}': output normalization is a plain-mode \
1968 declaration; remove either `output` or `stream_mode`",
1969 def.name
1970 )));
1971 }
1972 if let Some(format) = output.format.as_deref() {
1973 if format != "json" {
1974 return Err(invalid(format!(
1975 "SubprocessDef '{}': unknown output.format '{format}' \
1976 (supported: \"json\")",
1977 def.name
1978 )));
1979 }
1980 }
1981 if let Some(ptr) = output.result_ptr.as_deref() {
1982 if !ptr.starts_with('/') {
1983 return Err(invalid(format!(
1984 "SubprocessDef '{}': output.result_ptr '{ptr}' is not a \
1985 JSON Pointer (RFC 6901 — must start with '/')",
1986 def.name
1987 )));
1988 }
1989 }
1990 if let Some(ok_from) = output.ok_from.as_deref() {
1991 if ok_from != "exit_code" && !ok_from.starts_with('/') {
1992 return Err(invalid(format!(
1993 "SubprocessDef '{}': output.ok_from '{ok_from}' must be \
1994 \"exit_code\" or a JSON Pointer (starting with '/')",
1995 def.name
1996 )));
1997 }
1998 }
1999 }
2000
2001 let profile = agent_def.profile.as_ref();
2004 let system_prompt = profile
2005 .map(|p| p.system_prompt.clone())
2006 .filter(|s| !s.is_empty());
2007 let model = overrides
2008 .model
2009 .clone()
2010 .or_else(|| profile.and_then(|p| p.model.clone()));
2011 let tools: Vec<String> = if overrides.tools.is_empty() {
2012 profile.map(|p| p.tools.clone()).unwrap_or_default()
2013 } else {
2014 overrides.tools.clone()
2015 };
2016 let cwd = overrides.cwd.clone().or_else(|| def.cwd.clone());
2018 if let Some(c) = &cwd {
2019 validate_embed_placeholders(c, "overrides.cwd").map_err(&invalid)?;
2020 }
2021
2022 let program = def.argv[0].clone();
2023 let sp = ProcessSpawner {
2024 program,
2025 args: Vec::new(),
2026 use_stdin: def.stdin.is_some(),
2027 stream_mode,
2028 embed: Some(EmbedTemplate {
2029 argv: def.argv,
2030 stdin: def.stdin,
2031 env: def.env,
2032 cwd,
2033 output: def.output,
2034 system_prompt,
2035 model,
2036 tools_csv: tools.join(","),
2037 timeout: def.timeout_ms.map(std::time::Duration::from_millis),
2038 }),
2039 };
2040 Ok(sp)
2041 }
2042}
2043
2044impl SpawnerFactory for SubprocessProcessSpawnerFactory {
2045 fn build(
2046 &self,
2047 agent_def: &AgentDef,
2048 hint: Option<&Value>,
2049 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2050 if let Some(template) = hint.and_then(|h| h.get(SUBPROCESS_TEMPLATE_HINT_KEY)) {
2054 let overrides = hint.and_then(|h| h.get(SUBPROCESS_OVERRIDES_HINT_KEY));
2055 return Self::build_embed(agent_def, template, overrides).map(|sp| {
2056 let arc: Arc<dyn SpawnerAdapter> = Arc::new(sp);
2057 arc
2058 });
2059 }
2060 let agent_name = &agent_def.name;
2061 let spec = &agent_def.spec;
2062 let invalid = |msg: String| CompileError::InvalidSpec {
2063 name: agent_name.to_string(),
2064 msg,
2065 };
2066 let program = spec
2067 .get("program")
2068 .and_then(|v| v.as_str())
2069 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
2070 .to_string();
2071 let args: Vec<String> = spec
2072 .get("args")
2073 .and_then(|v| v.as_array())
2074 .map(|a| {
2075 a.iter()
2076 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2077 .collect()
2078 })
2079 .unwrap_or_default();
2080 let use_stdin = spec
2081 .get("use_stdin")
2082 .and_then(|v| v.as_bool())
2083 .unwrap_or(true);
2084 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
2085 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
2086 Some("sse_events") => Some(StreamMode::SseEvents),
2087 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
2088 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
2089 None => None,
2090 };
2091
2092 let mut sp = ProcessSpawner {
2093 program,
2094 args,
2095 use_stdin,
2096 stream_mode,
2097 embed: None,
2098 };
2099 if let Some(mode) = sp.stream_mode.clone() {
2100 sp = sp.stream_mode(mode);
2101 }
2102 Ok(Arc::new(sp))
2103 }
2104}
2105
2106pub struct LuaInProcessSpawnerFactory {
2137 registry: HashMap<String, WorkerFn>,
2138 bridges: HashMap<String, HostBridge>,
2139}
2140
2141#[derive(Clone)]
2153pub struct HostBridge(
2154 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
2155);
2156
2157impl HostBridge {
2158 pub fn new<F>(f: F) -> Self
2160 where
2161 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
2162 {
2163 Self(Arc::new(f))
2164 }
2165
2166 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
2170 (self.0)(arg)
2171 }
2172}
2173
2174#[derive(Clone)]
2181pub struct LuaScriptSource {
2182 pub source: String,
2184 pub label: String,
2187}
2188
2189impl LuaScriptSource {
2190 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
2192 Self {
2193 source: source.into(),
2194 label: label.into(),
2195 }
2196 }
2197}
2198
2199impl LuaInProcessSpawnerFactory {
2200 pub fn new() -> Self {
2202 Self {
2203 registry: HashMap::new(),
2204 bridges: HashMap::new(),
2205 }
2206 }
2207
2208 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
2215 self.bridges.insert(name.into(), bridge);
2216 self
2217 }
2218
2219 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
2237 let source = Arc::new(source);
2238 let bridges = Arc::new(self.bridges.clone());
2239 let wrapped: WorkerFn = Arc::new(move |inv| {
2240 let source = source.clone();
2241 let bridges = bridges.clone();
2242 Box::pin(run_lua_worker(source, bridges, inv))
2243 });
2244 self.registry.insert(fn_id.into(), wrapped);
2245 self
2246 }
2247}
2248
2249async fn run_lua_worker(
2251 source: Arc<LuaScriptSource>,
2252 bridges: Arc<HashMap<String, HostBridge>>,
2253 inv: crate::worker::adapter::WorkerInvocation,
2254) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
2255 use crate::worker::adapter::WorkerError;
2256 use mlua::LuaSerdeExt;
2257
2258 let label = source.label.clone();
2259 let outcome =
2260 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
2261 let lua = mlua::Lua::new();
2262 let g = lua.globals();
2263
2264 g.set("_PROMPT", inv.prompt.clone())
2266 .map_err(|e| format!("set _PROMPT: {e}"))?;
2267 g.set("_AGENT", inv.agent.clone())
2268 .map_err(|e| format!("set _AGENT: {e}"))?;
2269 g.set("_TASK_ID", inv.task_id.to_string())
2270 .map_err(|e| format!("set _TASK_ID: {e}"))?;
2271 g.set("_ATTEMPT", inv.attempt as i64)
2272 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
2273
2274 for (name, value) in
2283 crate::worker::agent_block::runtime::context_globals(inv.context.as_ref())
2284 {
2285 let lua_val = lua
2286 .to_value(&value)
2287 .map_err(|e| format!("{name} to_value: {e}"))?;
2288 g.set(name.as_str(), lua_val)
2289 .map_err(|e| format!("set {name}: {e}"))?;
2290 }
2291
2292 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
2294 let lua_val = lua
2295 .to_value(&json_val)
2296 .map_err(|e| format!("_CTX to_value: {e}"))?;
2297 g.set("_CTX", lua_val)
2298 .map_err(|e| format!("set _CTX: {e}"))?;
2299 }
2300
2301 if !bridges.is_empty() {
2303 let host = lua
2304 .create_table()
2305 .map_err(|e| format!("create host table: {e}"))?;
2306 for (name, bridge) in bridges.iter() {
2307 let bridge = bridge.clone();
2308 let bname = name.clone();
2309 let f = lua
2310 .create_function(move |lua, arg: mlua::Value| {
2311 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
2312 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
2313 })?;
2314 let result_json =
2315 bridge.call(json_arg).map_err(mlua::Error::external)?;
2316 lua.to_value(&result_json).map_err(|e| {
2317 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
2318 })
2319 })
2320 .map_err(|e| format!("create_function {name}: {e}"))?;
2321 host.set(name.as_str(), f)
2322 .map_err(|e| format!("host.{name} set: {e}"))?;
2323 }
2324 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
2325 }
2326
2327 let result: mlua::Value = lua
2329 .load(&source.source)
2330 .set_name(&source.label)
2331 .eval()
2332 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
2333
2334 let json_result: serde_json::Value = lua
2336 .from_value(result)
2337 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
2338
2339 let (value, ok) = match &json_result {
2340 serde_json::Value::Object(map)
2341 if map.contains_key("value") || map.contains_key("ok") =>
2342 {
2343 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
2344 let value = map.get("value").cloned().unwrap_or(json_result.clone());
2345 (value, ok)
2346 }
2347 _ => (json_result, true),
2348 };
2349 Ok((value, ok))
2350 })
2351 .await
2352 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
2353 .map_err(WorkerError::Failed)?;
2354
2355 Ok(crate::worker::adapter::WorkerResult {
2356 value: outcome.0,
2357 ok: outcome.1,
2358 stats: None,
2359 }
2360 .ensure_worker_kind("lua"))
2361}
2362
2363impl Default for LuaInProcessSpawnerFactory {
2364 fn default() -> Self {
2365 Self::new()
2366 }
2367}
2368
2369impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
2370 const KIND: AgentKind = AgentKind::Lua;
2371 type Worker = LuaWorker;
2372}
2373
2374impl SpawnerFactory for LuaInProcessSpawnerFactory {
2375 fn build(
2376 &self,
2377 agent_def: &AgentDef,
2378 _hint: Option<&Value>,
2379 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2380 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
2386 let label = agent_def
2387 .spec
2388 .get("label")
2389 .and_then(|v| v.as_str())
2390 .map(str::to_string)
2391 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
2392 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
2393 let bridges = Arc::new(self.bridges.clone());
2394 let wrapped: WorkerFn = Arc::new(move |inv| {
2395 let source = script.clone();
2396 let bridges = bridges.clone();
2397 Box::pin(run_lua_worker(source, bridges, inv))
2398 });
2399 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
2400 sp.registry.insert(agent_def.name.to_string(), wrapped);
2401 return Ok(Arc::new(sp));
2402 }
2403 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
2404 }
2405}
2406
2407pub struct RustFnInProcessSpawnerFactory {
2421 registry: HashMap<String, WorkerFn>,
2422}
2423
2424impl RustFnInProcessSpawnerFactory {
2425 pub fn new() -> Self {
2427 Self {
2428 registry: HashMap::new(),
2429 }
2430 }
2431
2432 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
2435 where
2436 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
2437 Fut: std::future::Future<
2438 Output = Result<
2439 crate::worker::adapter::WorkerResult,
2440 crate::worker::adapter::WorkerError,
2441 >,
2442 > + Send
2443 + 'static,
2444 {
2445 let f = Arc::new(f);
2446 let wrapped: WorkerFn = Arc::new(move |inv| {
2447 let f = f.clone();
2448 Box::pin(f(inv))
2449 });
2450 self.registry.insert(fn_id.into(), wrapped);
2451 self
2452 }
2453}
2454
2455impl Default for RustFnInProcessSpawnerFactory {
2456 fn default() -> Self {
2457 Self::new()
2458 }
2459}
2460
2461impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
2462 const KIND: AgentKind = AgentKind::RustFn;
2463 type Worker = RustFnWorker;
2464}
2465
2466impl SpawnerFactory for RustFnInProcessSpawnerFactory {
2467 fn build(
2468 &self,
2469 agent_def: &AgentDef,
2470 _hint: Option<&Value>,
2471 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2472 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
2473 }
2474}
2475
2476fn build_inproc_from_registry<W>(
2482 registry: &HashMap<String, WorkerFn>,
2483 agent_def: &AgentDef,
2484 kind_label: &str,
2485) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
2486where
2487 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
2488{
2489 let agent_name = &agent_def.name;
2490 let spec = &agent_def.spec;
2491 let invalid = |msg: String| CompileError::InvalidSpec {
2492 name: agent_name.to_string(),
2493 msg,
2494 };
2495 let fn_id = spec
2496 .get("fn_id")
2497 .and_then(|v| v.as_str())
2498 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
2499 let f = registry
2500 .get(fn_id)
2501 .cloned()
2502 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
2503 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
2504 sp.registry.insert(agent_name.to_string(), f);
2508 Ok(Arc::new(sp))
2509}
2510
2511pub struct LuaWorker {
2516 pub handler: crate::worker::WorkerJoinHandler,
2518}
2519
2520impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
2521 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2522 Self { handler }
2523 }
2524}
2525
2526#[async_trait::async_trait]
2527impl crate::worker::Worker for LuaWorker {
2528 fn id(&self) -> &crate::types::WorkerId {
2529 &self.handler.worker_id
2530 }
2531 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2532 self.handler.cancel.clone()
2533 }
2534 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2535 self.handler.await_completion().await
2536 }
2537}
2538
2539pub struct RustFnWorker {
2544 pub handler: crate::worker::WorkerJoinHandler,
2546}
2547
2548impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
2549 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2550 Self { handler }
2551 }
2552}
2553
2554#[async_trait::async_trait]
2555impl crate::worker::Worker for RustFnWorker {
2556 fn id(&self) -> &crate::types::WorkerId {
2557 &self.handler.worker_id
2558 }
2559 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2560 self.handler.cancel.clone()
2561 }
2562 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2563 self.handler.await_completion().await
2564 }
2565}
2566
2567pub struct OperatorSpawnerFactory {
2635 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
2636 slot_resolver: Arc<std::sync::RwLock<Option<Arc<dyn OperatorSlotResolver>>>>,
2639}
2640
2641impl OperatorSpawnerFactory {
2642 pub fn new() -> Self {
2644 Self {
2645 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
2646 slot_resolver: Arc::new(std::sync::RwLock::new(None)),
2647 }
2648 }
2649
2650 pub fn set_slot_resolver(&self, resolver: Arc<dyn OperatorSlotResolver>) -> &Self {
2659 *self
2660 .slot_resolver
2661 .write()
2662 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned") = Some(resolver);
2663 self
2664 }
2665
2666 pub fn resolve_operator(
2674 &self,
2675 slot: &str,
2676 agent: &str,
2677 ) -> Result<Arc<dyn Operator>, CompileError> {
2678 let invalid = |msg: String| CompileError::InvalidSpec {
2679 name: agent.to_string(),
2680 msg,
2681 };
2682 let resolver = self
2683 .slot_resolver
2684 .read()
2685 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned")
2686 .clone();
2687 if let Some(resolver) = resolver {
2688 return resolver.resolve(slot).ok_or_else(|| {
2689 invalid(format!(
2690 "operator_ref '{slot}': the installed OperatorSlotResolver serves no such \
2691 Operator seat. The seat is declared by Blueprint.operators[]; nothing is \
2692 resolved from the factory's own registry here, because falling back to it \
2693 would dispatch this agent to a backend the seat does not name."
2694 ))
2695 });
2696 }
2697 let operators = self
2698 .operators
2699 .read()
2700 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
2701 operators.get(slot).cloned().ok_or_else(|| {
2702 let mut names: Vec<String> = operators.keys().cloned().collect();
2703 names.sort();
2704 let names_list = if names.is_empty() {
2705 "<none>".to_string()
2706 } else {
2707 names.join(", ")
2708 };
2709 invalid(format!(
2710 "operator_ref '{slot}' not registered in factory. \
2711 Registered sids: [{names_list}]. \
2712 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
2713 ))
2714 })
2715 }
2716
2717 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
2723 self.operators
2724 .write()
2725 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2726 .insert(id.into(), op);
2727 self
2728 }
2729
2730 pub fn unregister_operator(&self, id: &str) -> &Self {
2733 self.operators
2734 .write()
2735 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2736 .remove(id);
2737 self
2738 }
2739}
2740
2741impl Default for OperatorSpawnerFactory {
2742 fn default() -> Self {
2743 Self::new()
2744 }
2745}
2746
2747impl SpawnerFactoryKind for OperatorSpawnerFactory {
2748 const KIND: AgentKind = AgentKind::Operator;
2749 type Worker = crate::operator::OperatorWorker;
2750}
2751
2752impl SpawnerFactory for OperatorSpawnerFactory {
2753 fn build(
2758 &self,
2759 agent_def: &AgentDef,
2760 _hint: Option<&Value>,
2761 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2762 let agent_name = &agent_def.name;
2763 let spec = &agent_def.spec;
2764 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
2770 let invalid = |msg: String| CompileError::InvalidSpec {
2771 name: agent_name.to_string(),
2772 msg,
2773 };
2774 let op_ref = spec
2775 .get("operator_ref")
2776 .and_then(|v| v.as_str())
2777 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
2778 let op = self.resolve_operator(op_ref, agent_name)?;
2782
2783 let worker_binding = agent_def
2790 .profile
2791 .as_ref()
2792 .and_then(|p| p.worker_binding.as_ref())
2793 .map(|variant| WorkerBinding {
2794 variant: variant.clone(),
2795 tools: agent_def
2796 .profile
2797 .as_ref()
2798 .map(|p| p.tools.clone())
2799 .unwrap_or_default(),
2800 request_digest: None,
2804 requested_model: None,
2805 });
2806 if op.requires_worker_binding() && worker_binding.is_none() {
2807 return Err(invalid(format!(
2814 "{WORKER_BINDING_REQUIRED_MSG_PREFIX}. \
2815 Fix by either: \
2816 (a) if authoring the Blueprint JSON directly, add \
2817 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
2818 to the JSON literal; or \
2819 (b) if using an $agent_md file ref, add \
2820 `worker_binding: <subagent-type>` to the agent .md frontmatter."
2821 )));
2822 }
2823 Ok(Arc::new(OperatorSpawner::new(
2824 op,
2825 system_prompt,
2826 worker_binding,
2827 )))
2828 }
2829}
2830
2831#[cfg(test)]
2832mod operator_spawner_factory_worker_binding_tests {
2833 use super::*;
2834 use crate::blueprint::AgentProfile;
2835 use crate::core::ctx::Ctx;
2836 use crate::types::CapToken;
2837 use crate::worker::adapter::{WorkerError, WorkerResult};
2838
2839 struct StubOperator {
2844 requires_binding: bool,
2845 }
2846
2847 #[async_trait]
2848 impl Operator for StubOperator {
2849 async fn execute(
2850 &self,
2851 _ctx: &Ctx,
2852 _system: Option<String>,
2853 _prompt: Value,
2854 _worker: Option<WorkerBinding>,
2855 _worker_token: CapToken,
2856 ) -> Result<WorkerResult, WorkerError> {
2857 Ok(WorkerResult {
2858 value: Value::Null,
2859 ok: true,
2860 stats: None,
2861 })
2862 }
2863
2864 fn requires_worker_binding(&self) -> bool {
2865 self.requires_binding
2866 }
2867 }
2868
2869 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
2870 AgentDef {
2871 name: "test-agent".to_string(),
2872 kind: AgentKind::Operator,
2873 spec: serde_json::json!({ "operator_ref": "op1" }),
2874 profile,
2875 meta: None,
2876 runner: None,
2877 runner_ref: None,
2878 verdict: None,
2879 lints: None,
2880 }
2881 }
2882
2883 #[test]
2884 fn build_fails_loud_when_binding_required_but_absent() {
2885 let factory = OperatorSpawnerFactory::new();
2886 factory.register_operator(
2887 "op1",
2888 Arc::new(StubOperator {
2889 requires_binding: true,
2890 }) as Arc<dyn Operator>,
2891 );
2892 let def = agent_def_with(Some(AgentProfile::default()));
2893 match factory.build(&def, None) {
2894 Err(CompileError::InvalidSpec { name, msg }) => {
2895 assert_eq!(name, "test-agent");
2896 assert!(
2897 msg.contains("worker_binding is required"),
2898 "unexpected message: {msg}"
2899 );
2900 assert!(
2904 msg.contains("agents[N].profile.worker_binding"),
2905 "message missing JSON-direct hint (issue #9): {msg}"
2906 );
2907 assert!(
2908 msg.contains("agent .md frontmatter"),
2909 "message missing $agent_md hint: {msg}"
2910 );
2911 }
2912 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
2913 Ok(_) => panic!("expected compile-time failure, got Ok"),
2914 }
2915 }
2916
2917 #[test]
2924 fn factory_error_message_carries_the_shared_prefix_and_specializes_the_diagnostic() {
2925 let factory = OperatorSpawnerFactory::new();
2926 factory.register_operator(
2927 "op1",
2928 Arc::new(StubOperator {
2929 requires_binding: true,
2930 }) as Arc<dyn Operator>,
2931 );
2932 let def = agent_def_with(Some(AgentProfile::default()));
2933 let err = match factory.build(&def, None) {
2934 Err(err) => err,
2935 Ok(_) => panic!("expected compile-time failure, got Ok"),
2936 };
2937 match &err {
2938 CompileError::InvalidSpec { msg, .. } => {
2939 assert!(
2940 msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX),
2941 "factory message must start with the shared prefix, got: {msg}"
2942 );
2943 }
2944 other => panic!("expected InvalidSpec, got: {other:?}"),
2945 }
2946 let d = mlua_swarm_diag::Diagnostic::from(&err);
2947 assert_eq!(d.kind, "worker-binding-missing");
2948 }
2949
2950 #[test]
2951 fn build_succeeds_when_binding_required_and_present() {
2952 let factory = OperatorSpawnerFactory::new();
2953 factory.register_operator(
2954 "op1",
2955 Arc::new(StubOperator {
2956 requires_binding: true,
2957 }) as Arc<dyn Operator>,
2958 );
2959 let profile = AgentProfile {
2960 worker_binding: Some("code-worker".to_string()),
2961 tools: vec!["Read".to_string(), "Edit".to_string()],
2962 ..Default::default()
2963 };
2964 let def = agent_def_with(Some(profile));
2965 assert!(
2966 factory.build(&def, None).is_ok(),
2967 "expected Ok when worker_binding is declared"
2968 );
2969 }
2970
2971 #[test]
2972 fn build_succeeds_when_binding_not_required_and_absent() {
2973 let factory = OperatorSpawnerFactory::new();
2974 factory.register_operator(
2975 "op1",
2976 Arc::new(StubOperator {
2977 requires_binding: false,
2978 }) as Arc<dyn Operator>,
2979 );
2980 let def = agent_def_with(Some(AgentProfile::default()));
2981 assert!(
2982 factory.build(&def, None).is_ok(),
2983 "backends that don't require a binding must not be gated by its absence"
2984 );
2985 }
2986}
2987
2988#[cfg(test)]
2996mod lua_inline_source_tests {
2997 use super::*;
2998 use crate::types::{CapToken, Role, StepId};
2999
3000 fn agent(name: &str, spec: Value) -> AgentDef {
3001 AgentDef {
3002 name: name.to_string(),
3003 kind: AgentKind::Lua,
3004 spec,
3005 profile: None,
3006 meta: None,
3007 runner: None,
3008 runner_ref: None,
3009 verdict: None,
3010 lints: None,
3011 }
3012 }
3013
3014 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
3015 crate::worker::adapter::WorkerInvocation::new(
3016 CapToken {
3017 agent_id: "a".into(),
3018 role: Role::Worker,
3019 scopes: vec!["*".into()],
3020 issued_at: 0,
3021 expire_at: u64::MAX / 2,
3022 max_uses: None,
3023 nonce: "test-nonce".into(),
3024 sig_hex: "".into(),
3025 },
3026 StepId::parse("ST-test").expect("StepId parse"),
3027 1,
3028 "g",
3029 prompt,
3030 )
3031 }
3032
3033 #[test]
3034 fn build_accepts_inline_source_without_pre_registration() {
3035 let factory = LuaInProcessSpawnerFactory::new();
3036 let def = agent(
3037 "g",
3038 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
3039 );
3040 assert!(
3041 factory.build(&def, None).is_ok(),
3042 "inline spec.source must build without a pre-registered fn_id"
3043 );
3044 }
3045
3046 #[test]
3047 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
3048 let factory = LuaInProcessSpawnerFactory::new();
3049 let def = agent("g", serde_json::json!({}));
3050 match factory.build(&def, None) {
3051 Err(CompileError::InvalidSpec { msg, .. }) => {
3052 assert!(
3053 msg.contains("fn_id"),
3054 "empty spec must still surface the fn_id-required message: {msg}"
3055 );
3056 }
3057 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3058 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
3061 }
3062 }
3063
3064 #[tokio::test]
3068 async fn inline_source_evaluates_and_marshals_result() {
3069 let source =
3070 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
3071 let out = run_lua_worker(
3072 std::sync::Arc::new(source),
3073 std::sync::Arc::new(HashMap::new()),
3074 test_invocation("hello"),
3075 )
3076 .await
3077 .expect("lua worker ok");
3078 assert_eq!(out.value, serde_json::json!("hello!"));
3079 assert!(out.ok);
3080 }
3081
3082 #[tokio::test]
3083 async fn inline_source_can_signal_agent_level_failure() {
3084 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
3087 let out = run_lua_worker(
3088 std::sync::Arc::new(source),
3089 std::sync::Arc::new(HashMap::new()),
3090 test_invocation("input"),
3091 )
3092 .await
3093 .expect("lua worker ok");
3094 assert_eq!(out.value, serde_json::json!("nope"));
3095 assert!(!out.ok);
3096 }
3097}
3098
3099#[cfg(test)]
3102mod meta_ref_validation_tests {
3103 use super::*;
3104 use crate::blueprint::{AgentMeta, MetaDef};
3105 use crate::worker::adapter::WorkerResult;
3106
3107 fn registry_with_echo() -> SpawnerRegistry {
3108 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3109 Ok(WorkerResult {
3110 value: Value::String(inv.prompt),
3111 ok: true,
3112 stats: None,
3113 })
3114 });
3115 let mut reg = SpawnerRegistry::new();
3116 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3117 reg
3118 }
3119
3120 fn rustfn_agent(name: &str) -> AgentDef {
3121 AgentDef {
3122 name: name.to_string(),
3123 kind: AgentKind::RustFn,
3124 spec: serde_json::json!({ "fn_id": "echo" }),
3125 profile: None,
3126 meta: None,
3127 runner: None,
3128 runner_ref: None,
3129 verdict: None,
3130 lints: None,
3131 }
3132 }
3133
3134 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
3135 FlowNode::Step {
3136 ref_: agent_ref.to_string(),
3137 in_,
3138 out: Expr::Path {
3139 at: "$.output".parse().expect("literal test path: $.output"),
3140 },
3141 }
3142 }
3143
3144 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
3145 Blueprint {
3146 schema_version: crate::blueprint::current_schema_version(),
3147 id: "meta-ref-ut".into(),
3148 flow,
3149 agents,
3150 operators: vec![],
3151 metas,
3152 hints: Default::default(),
3153 strategy: Default::default(),
3154 metadata: BlueprintMetadata::default(),
3155 spawner_hints: Default::default(),
3156 default_agent_kind: AgentKind::Operator,
3157 default_operator_kind: None,
3158 default_init_ctx: None,
3159 default_agent_ctx: None,
3160 default_context_policy: None,
3161 projection_placement: None,
3162 audits: vec![],
3163 degradation_policy: None,
3164 runners: vec![],
3165 default_runner: None,
3166 subprocesses: vec![],
3167 check_policy: None,
3168 blueprint_ref_includes: Vec::new(),
3169 }
3170 }
3171
3172 #[test]
3173 fn valid_meta_ref_compiles() {
3174 let mut agent = rustfn_agent("worker");
3175 agent.meta = Some(AgentMeta {
3176 meta_ref: Some("shared".to_string()),
3177 ..Default::default()
3178 });
3179 let bp = minimal_bp(
3180 vec![agent],
3181 vec![MetaDef {
3182 name: "shared".into(),
3183 ctx: serde_json::json!({ "k": "v" }),
3184 }],
3185 simple_flow(
3186 "worker",
3187 Expr::Path {
3188 at: "$.input".parse().expect("literal test path: $.input"),
3189 },
3190 ),
3191 );
3192 let compiler = Compiler::new(registry_with_echo());
3193 assert!(
3194 compiler.compile(&bp).is_ok(),
3195 "a resolvable AgentMeta.meta_ref must compile"
3196 );
3197 }
3198
3199 #[test]
3200 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
3201 let mut agent = rustfn_agent("worker");
3202 agent.meta = Some(AgentMeta {
3203 meta_ref: Some("missing".to_string()),
3204 ..Default::default()
3205 });
3206 let bp = minimal_bp(
3207 vec![agent],
3208 vec![],
3209 simple_flow(
3210 "worker",
3211 Expr::Path {
3212 at: "$.input".parse().expect("literal test path: $.input"),
3213 },
3214 ),
3215 );
3216 let compiler = Compiler::new(registry_with_echo());
3217 match compiler.compile(&bp) {
3218 Err(CompileError::UnresolvedMetaRef {
3219 where_,
3220 meta_ref,
3221 defined,
3222 }) => {
3223 assert!(
3224 where_.contains("worker"),
3225 "where_ must name the agent: {where_}"
3226 );
3227 assert_eq!(meta_ref, "missing");
3228 assert!(defined.is_empty());
3229 }
3230 Err(other) => {
3231 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3232 }
3233 Ok(_) => panic!("expected compile-time failure, got Ok"),
3234 }
3235 }
3236
3237 #[test]
3238 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
3239 let agent = rustfn_agent("worker");
3240 let in_ = Expr::Lit {
3241 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
3242 };
3243 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
3244 let compiler = Compiler::new(registry_with_echo());
3245 match compiler.compile(&bp) {
3246 Err(CompileError::UnresolvedMetaRef {
3247 where_, meta_ref, ..
3248 }) => {
3249 assert!(
3250 where_.contains("worker"),
3251 "where_ must name the offending step: {where_}"
3252 );
3253 assert_eq!(meta_ref, "missing");
3254 }
3255 Err(other) => {
3256 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3257 }
3258 Ok(_) => panic!("expected compile-time failure, got Ok"),
3259 }
3260 }
3261
3262 #[test]
3263 fn path_op_input_with_no_static_envelope_compiles_fine() {
3264 let agent = rustfn_agent("worker");
3265 let bp = minimal_bp(
3266 vec![agent],
3267 vec![],
3268 simple_flow(
3269 "worker",
3270 Expr::Path {
3271 at: "$.input".parse().expect("literal test path: $.input"),
3272 },
3273 ),
3274 );
3275 let compiler = Compiler::new(registry_with_echo());
3276 assert!(
3277 compiler.compile(&bp).is_ok(),
3278 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
3279 );
3280 }
3281}
3282
3283#[cfg(test)]
3285mod audit_agent_validation_tests {
3286 use super::*;
3287 use crate::worker::adapter::WorkerResult;
3288 use mlua_swarm_schema::{AuditDef, AuditMode};
3289
3290 fn registry_with_echo() -> SpawnerRegistry {
3291 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3292 Ok(WorkerResult {
3293 value: Value::String(inv.prompt),
3294 ok: true,
3295 stats: None,
3296 })
3297 });
3298 let mut reg = SpawnerRegistry::new();
3299 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3300 reg
3301 }
3302
3303 fn rustfn_agent(name: &str) -> AgentDef {
3304 AgentDef {
3305 name: name.to_string(),
3306 kind: AgentKind::RustFn,
3307 spec: serde_json::json!({ "fn_id": "echo" }),
3308 profile: None,
3309 meta: None,
3310 runner: None,
3311 runner_ref: None,
3312 verdict: None,
3313 lints: None,
3314 }
3315 }
3316
3317 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
3318 Blueprint {
3319 schema_version: crate::blueprint::current_schema_version(),
3320 id: "audit-ref-ut".into(),
3321 flow: FlowNode::Step {
3322 ref_: "worker".to_string(),
3323 in_: Expr::Path {
3324 at: "$.input".parse().expect("literal test path: $.input"),
3325 },
3326 out: Expr::Path {
3327 at: "$.output".parse().expect("literal test path: $.output"),
3328 },
3329 },
3330 agents,
3331 operators: vec![],
3332 metas: vec![],
3333 hints: Default::default(),
3334 strategy: Default::default(),
3335 metadata: BlueprintMetadata::default(),
3336 spawner_hints: Default::default(),
3337 default_agent_kind: AgentKind::Operator,
3338 default_operator_kind: None,
3339 default_init_ctx: None,
3340 default_agent_ctx: None,
3341 default_context_policy: None,
3342 projection_placement: None,
3343 audits,
3344 degradation_policy: None,
3345 runners: vec![],
3346 default_runner: None,
3347 subprocesses: vec![],
3348 check_policy: None,
3349 blueprint_ref_includes: Vec::new(),
3350 }
3351 }
3352
3353 #[test]
3354 fn unresolved_audit_agent_is_a_loud_compile_error() {
3355 let bp = minimal_bp(
3356 vec![rustfn_agent("worker")],
3357 vec![AuditDef {
3358 agent: "missing-auditor".to_string(),
3359 steps: None,
3360 mode: AuditMode::default(),
3361 }],
3362 );
3363 let compiler = Compiler::new(registry_with_echo());
3364 match compiler.compile(&bp) {
3365 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
3366 assert_eq!(agent, "missing-auditor");
3367 assert_eq!(defined, vec!["worker".to_string()]);
3368 }
3369 Err(other) => {
3370 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
3371 }
3372 Ok(_) => panic!("expected compile-time failure, got Ok"),
3373 }
3374 }
3375
3376 #[test]
3377 fn resolved_audit_agent_compiles_fine() {
3378 let bp = minimal_bp(
3379 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
3380 vec![AuditDef {
3381 agent: "auditor".to_string(),
3382 steps: None,
3383 mode: AuditMode::default(),
3384 }],
3385 );
3386 let compiler = Compiler::new(registry_with_echo());
3387 assert!(
3388 compiler.compile(&bp).is_ok(),
3389 "an audits[].agent that names a declared AgentDef must compile"
3390 );
3391 }
3392}
3393
3394#[cfg(test)]
3403mod operator_ref_resolution_tests {
3404 use super::*;
3405 use crate::core::ctx::Ctx;
3406 use crate::types::CapToken;
3407 use crate::worker::adapter::{WorkerError, WorkerResult};
3408 use std::sync::Mutex;
3409
3410 type Seen = Arc<Mutex<Vec<(String, Option<Value>)>>>;
3412
3413 struct RecordingOperatorFactory {
3417 seen: Seen,
3418 }
3419
3420 impl SpawnerFactory for RecordingOperatorFactory {
3421 fn build(
3422 &self,
3423 agent_def: &AgentDef,
3424 hint: Option<&Value>,
3425 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3426 self.seen
3427 .lock()
3428 .expect("RecordingOperatorFactory.seen poisoned")
3429 .push((agent_def.name.clone(), hint.cloned()));
3430 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3431 let worker: WorkerFn = Arc::new(|_inv| {
3432 Box::pin(async move {
3433 Ok(WorkerResult {
3434 value: Value::Null,
3435 ok: true,
3436 stats: None,
3437 })
3438 })
3439 });
3440 spawner.registry.insert(agent_def.name.clone(), worker);
3441 Ok(Arc::new(spawner))
3442 }
3443 }
3444
3445 impl SpawnerFactoryKind for RecordingOperatorFactory {
3446 const KIND: AgentKind = AgentKind::Operator;
3447 type Worker = crate::operator::OperatorWorker;
3448 }
3449
3450 struct RecordingLuaFactory {
3453 seen: Seen,
3454 }
3455
3456 impl SpawnerFactory for RecordingLuaFactory {
3457 fn build(
3458 &self,
3459 agent_def: &AgentDef,
3460 hint: Option<&Value>,
3461 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3462 self.seen
3463 .lock()
3464 .expect("RecordingLuaFactory.seen poisoned")
3465 .push((agent_def.name.clone(), hint.cloned()));
3466 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3467 let worker: WorkerFn = Arc::new(|_inv| {
3468 Box::pin(async move {
3469 Ok(WorkerResult {
3470 value: Value::Null,
3471 ok: true,
3472 stats: None,
3473 })
3474 })
3475 });
3476 spawner.registry.insert(agent_def.name.clone(), worker);
3477 Ok(Arc::new(spawner))
3478 }
3479 }
3480
3481 impl SpawnerFactoryKind for RecordingLuaFactory {
3482 const KIND: AgentKind = AgentKind::Lua;
3483 type Worker = LuaWorker;
3484 }
3485
3486 fn recording_compiler() -> (Compiler, Seen, Seen) {
3487 let operator_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3488 let lua_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3489 let mut registry = SpawnerRegistry::new();
3490 registry.register::<RecordingOperatorFactory>(Arc::new(RecordingOperatorFactory {
3491 seen: operator_seen.clone(),
3492 }));
3493 registry.register::<RecordingLuaFactory>(Arc::new(RecordingLuaFactory {
3494 seen: lua_seen.clone(),
3495 }));
3496 (Compiler::new(registry), operator_seen, lua_seen)
3497 }
3498
3499 fn bp_with_operator_and_lua_agents() -> Blueprint {
3503 serde_json::from_value(serde_json::json!({
3504 "schema_version": crate::blueprint::current_schema_version(),
3505 "id": "operator-pin-ut",
3506 "flow": {
3507 "kind": "step",
3508 "ref": "planner",
3509 "in": { "op": "path", "at": "$.input" },
3510 "out": { "op": "path", "at": "$.output" }
3511 },
3512 "agents": [
3513 {
3514 "name": "planner",
3515 "kind": "operator",
3516 "spec": { "operator_ref": "main-ai" }
3517 },
3518 {
3519 "name": "scorer",
3520 "kind": "lua",
3521 "spec": { "source": "return { value = 1, ok = true }" }
3522 }
3523 ],
3524 "operators": [{ "name": "main-ai" }],
3525 "hints": { "per_agent": { "planner": { "authored": "keep-me" } } },
3526 "strategy": { "strict_refs": false }
3527 }))
3528 .expect("test Blueprint literal")
3529 }
3530
3531 fn hint_for(seen: &Seen, agent: &str) -> Option<Value> {
3532 seen.lock()
3533 .expect("seen poisoned")
3534 .iter()
3535 .find(|(name, _)| name == agent)
3536 .map(|(_, hint)| hint.clone())
3537 .expect("agent was never built")
3538 }
3539
3540 #[test]
3545 fn the_compile_hands_the_factory_the_authored_hint_untouched() {
3546 let (compiler, operator_seen, lua_seen) = recording_compiler();
3547 let bp = bp_with_operator_and_lua_agents();
3548 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3549 compiler.compile_bound(&bp, &bound).expect("compile");
3550
3551 assert_eq!(
3552 hint_for(&operator_seen, "planner"),
3553 Some(serde_json::json!({ "authored": "keep-me" })),
3554 "the compile must hand over the authored hint verbatim"
3555 );
3556 assert_eq!(
3557 hint_for(&lua_seen, "scorer"),
3558 None,
3559 "an agent with no authored hint must still be built with None"
3560 );
3561 }
3562
3563 #[test]
3566 fn a_non_object_authored_hint_is_none_of_the_compilers_business() {
3567 let (compiler, _operator_seen, _lua_seen) = recording_compiler();
3568 let mut bp = bp_with_operator_and_lua_agents();
3569 bp.hints
3570 .per_agent
3571 .insert("planner".to_string(), Value::String("not-an-object".into()));
3572 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3573 assert!(
3574 compiler.compile_bound(&bp, &bound).is_ok(),
3575 "the compile must accept whatever hint shape the author declared"
3576 );
3577 }
3578
3579 struct StubOperator {
3585 requires_binding: bool,
3586 }
3587
3588 #[async_trait]
3589 impl Operator for StubOperator {
3590 async fn execute(
3591 &self,
3592 _ctx: &Ctx,
3593 _system: Option<String>,
3594 _prompt: Value,
3595 _worker: Option<WorkerBinding>,
3596 _worker_token: CapToken,
3597 ) -> Result<WorkerResult, WorkerError> {
3598 Ok(WorkerResult {
3599 value: Value::Null,
3600 ok: true,
3601 stats: None,
3602 })
3603 }
3604
3605 fn requires_worker_binding(&self) -> bool {
3606 self.requires_binding
3607 }
3608 }
3609
3610 fn operator_agent() -> AgentDef {
3611 AgentDef {
3612 name: "planner".to_string(),
3613 kind: AgentKind::Operator,
3614 spec: serde_json::json!({ "operator_ref": "main-ai" }),
3615 profile: None,
3616 meta: None,
3617 runner: None,
3618 runner_ref: None,
3619 verdict: None,
3620 lints: None,
3621 }
3622 }
3623
3624 struct StubResolver {
3628 seats: Vec<&'static str>,
3629 asked: Mutex<Vec<String>>,
3630 }
3631
3632 impl OperatorSlotResolver for StubResolver {
3633 fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>> {
3634 self.asked
3635 .lock()
3636 .expect("StubResolver.asked poisoned")
3637 .push(slot.to_string());
3638 self.seats.contains(&slot).then(|| {
3639 Arc::new(StubOperator {
3640 requires_binding: false,
3641 }) as Arc<dyn Operator>
3642 })
3643 }
3644 }
3645
3646 #[test]
3651 fn an_installed_resolver_answers_the_seat_and_the_registry_is_not_consulted() {
3652 let factory = OperatorSpawnerFactory::new();
3653 factory.register_operator(
3654 "main-ai",
3655 Arc::new(StubOperator {
3656 requires_binding: true,
3657 }) as Arc<dyn Operator>,
3658 );
3659 let resolver = Arc::new(StubResolver {
3660 seats: vec!["main-ai"],
3661 asked: Mutex::new(Vec::new()),
3662 });
3663 factory.set_slot_resolver(resolver.clone());
3664
3665 assert!(
3666 factory.build(&operator_agent(), None).is_ok(),
3667 "the installed resolver must answer the seat, not the registry entry \
3668 registered under the same name"
3669 );
3670 assert_eq!(
3671 *resolver.asked.lock().expect("asked"),
3672 vec!["main-ai".to_string()],
3673 "the resolver is asked for the seat the AgentDef declares"
3674 );
3675 }
3676
3677 #[test]
3681 fn a_resolver_miss_fails_loud_and_never_falls_back_to_the_registry() {
3682 let factory = OperatorSpawnerFactory::new();
3683 factory.register_operator(
3684 "main-ai",
3685 Arc::new(StubOperator {
3686 requires_binding: false,
3687 }) as Arc<dyn Operator>,
3688 );
3689 factory.set_slot_resolver(Arc::new(StubResolver {
3690 seats: vec!["some-other-seat"],
3691 asked: Mutex::new(Vec::new()),
3692 }));
3693
3694 match factory.build(&operator_agent(), None) {
3695 Err(CompileError::InvalidSpec { name, msg }) => {
3696 assert_eq!(name, "planner");
3697 assert!(
3698 msg.contains("main-ai"),
3699 "message must name the seat that went unserved: {msg}"
3700 );
3701 assert!(
3702 msg.contains("OperatorSlotResolver"),
3703 "message must say which side refused, so the wiring is the \
3704 obvious suspect: {msg}"
3705 );
3706 }
3707 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3708 Ok(_) => panic!(
3709 "an unserved seat must fail the compile, not silently resolve the \
3710 registry entry"
3711 ),
3712 }
3713 }
3714
3715 #[test]
3718 fn without_a_resolver_the_registry_answers_with_the_historical_message() {
3719 let factory = OperatorSpawnerFactory::new();
3720 match factory.build(&operator_agent(), None) {
3721 Err(CompileError::InvalidSpec { msg, .. }) => {
3722 assert!(
3723 msg.contains("operator_ref 'main-ai' not registered in factory"),
3724 "the registry-side message must stay the historical one: {msg}"
3725 );
3726 }
3727 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3728 Ok(_) => panic!("an unregistered seat must still fail"),
3729 }
3730 factory.register_operator(
3731 "main-ai",
3732 Arc::new(StubOperator {
3733 requires_binding: false,
3734 }) as Arc<dyn Operator>,
3735 );
3736 assert!(
3737 factory.build(&operator_agent(), None).is_ok(),
3738 "a registered backend must still resolve the seat directly"
3739 );
3740 }
3741}
3742
3743#[cfg(test)]
3746mod projection_placement_compile_tests {
3747 use super::*;
3748 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
3749 use crate::worker::adapter::WorkerResult;
3750 use mlua_swarm_schema::ProjectionPlacementSpec;
3751
3752 fn registry_with_echo() -> SpawnerRegistry {
3753 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3754 Ok(WorkerResult {
3755 value: Value::String(inv.prompt),
3756 ok: true,
3757 stats: None,
3758 })
3759 });
3760 let mut reg = SpawnerRegistry::new();
3761 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3762 reg
3763 }
3764
3765 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
3766 Blueprint {
3767 schema_version: crate::blueprint::current_schema_version(),
3768 id: "projection-placement-ut".into(),
3769 flow: FlowNode::Step {
3770 ref_: "worker".to_string(),
3771 in_: Expr::Path {
3772 at: "$.input".parse().expect("literal test path: $.input"),
3773 },
3774 out: Expr::Path {
3775 at: "$.output".parse().expect("literal test path: $.output"),
3776 },
3777 },
3778 agents: vec![AgentDef {
3779 name: "worker".to_string(),
3780 kind: AgentKind::RustFn,
3781 spec: serde_json::json!({ "fn_id": "echo" }),
3782 profile: None,
3783 meta: None,
3784 runner: None,
3785 runner_ref: None,
3786 verdict: None,
3787 lints: None,
3788 }],
3789 operators: vec![],
3790 metas: vec![],
3791 hints: Default::default(),
3792 strategy: Default::default(),
3793 metadata: BlueprintMetadata::default(),
3794 spawner_hints: Default::default(),
3795 default_agent_kind: AgentKind::Operator,
3796 default_operator_kind: None,
3797 default_init_ctx: None,
3798 default_agent_ctx: None,
3799 default_context_policy: None,
3800 projection_placement,
3801 audits: vec![],
3802 degradation_policy: None,
3803 runners: vec![],
3804 default_runner: None,
3805 subprocesses: vec![],
3806 check_policy: None,
3807 blueprint_ref_includes: Vec::new(),
3808 }
3809 }
3810
3811 #[test]
3812 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
3813 let bp = minimal_bp(None);
3814 let compiled = Compiler::new(registry_with_echo())
3815 .compile(&bp)
3816 .expect("undeclared projection_placement compiles");
3817 assert_eq!(
3818 *compiled.projection_placement,
3819 ProjectionPlacement::default()
3820 );
3821 }
3822
3823 #[test]
3824 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
3825 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3826 root: Some("project_root".to_string()),
3827 dir_template: Some("custom/{task_id}/out".to_string()),
3828 }));
3829 let compiled = Compiler::new(registry_with_echo())
3830 .compile(&bp)
3831 .expect("valid projection_placement compiles");
3832 assert_eq!(
3833 compiled.projection_placement.root_preference,
3834 RootPreference::ProjectRoot
3835 );
3836 assert_eq!(
3837 compiled.projection_placement.dir_template,
3838 "custom/{task_id}/out"
3839 );
3840 }
3841
3842 #[test]
3843 fn declared_invalid_dir_template_rejects_compile() {
3844 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3845 root: None,
3846 dir_template: Some("workspace/tasks/ctx".to_string()), }));
3848 match Compiler::new(registry_with_echo()).compile(&bp) {
3849 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3850 Err(other) => {
3851 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3852 }
3853 Ok(_) => {
3854 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
3855 }
3856 }
3857 }
3858
3859 #[test]
3860 fn declared_invalid_root_literal_rejects_compile() {
3861 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3862 root: Some("nope".to_string()),
3863 dir_template: None,
3864 }));
3865 match Compiler::new(registry_with_echo()).compile(&bp) {
3866 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3867 Err(other) => {
3868 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3869 }
3870 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
3871 }
3872 }
3873}
3874
3875#[cfg(test)]
3877mod verdict_contract_lint_tests {
3878 use super::*;
3879 use crate::worker::adapter::WorkerResult;
3880
3881 fn registry_with_echo() -> SpawnerRegistry {
3882 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3883 Ok(WorkerResult {
3884 value: Value::String(inv.prompt),
3885 ok: true,
3886 stats: None,
3887 })
3888 });
3889 let mut reg = SpawnerRegistry::new();
3890 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3891 reg
3892 }
3893
3894 fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
3895 AgentDef {
3896 name: "gate".to_string(),
3897 kind: AgentKind::RustFn,
3898 spec: serde_json::json!({ "fn_id": "echo" }),
3899 profile: None,
3900 meta: None,
3901 runner: None,
3902 runner_ref: None,
3903 verdict,
3904 lints: None,
3905 }
3906 }
3907
3908 fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
3909 Blueprint {
3910 schema_version: crate::blueprint::current_schema_version(),
3911 id: "verdict-contract-ut".into(),
3912 flow,
3913 agents: vec![agent],
3914 operators: vec![],
3915 metas: vec![],
3916 hints: Default::default(),
3917 strategy: Default::default(),
3918 metadata: BlueprintMetadata::default(),
3919 spawner_hints: Default::default(),
3920 default_agent_kind: AgentKind::Operator,
3921 default_operator_kind: None,
3922 default_init_ctx: None,
3923 default_agent_ctx: None,
3924 default_context_policy: None,
3925 projection_placement: None,
3926 audits: vec![],
3927 degradation_policy: None,
3928 runners: vec![],
3929 default_runner: None,
3930 subprocesses: vec![],
3931 check_policy: None,
3932 blueprint_ref_includes: Vec::new(),
3933 }
3934 }
3935
3936 fn step(ref_: &str, out_path: &str) -> FlowNode {
3937 FlowNode::Step {
3938 ref_: ref_.to_string(),
3939 in_: Expr::Lit { value: Value::Null },
3940 out: Expr::Path {
3941 at: out_path.parse().expect("literal test path"),
3942 },
3943 }
3944 }
3945
3946 fn noop() -> FlowNode {
3947 FlowNode::Seq { children: vec![] }
3948 }
3949
3950 fn eq_cond(path: &str, lit: &str) -> Expr {
3951 Expr::Eq {
3952 lhs: Box::new(Expr::Path {
3953 at: path.parse().expect("literal test path"),
3954 }),
3955 rhs: Box::new(Expr::Lit {
3956 value: Value::String(lit.to_string()),
3957 }),
3958 }
3959 }
3960
3961 fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
3962 FlowNode::Branch {
3963 cond,
3964 then_: Box::new(then_),
3965 else_: Box::new(else_),
3966 }
3967 }
3968
3969 fn body_contract(values: &[&str]) -> VerdictContract {
3970 VerdictContract {
3971 channel: VerdictChannel::Body,
3972 values: values.iter().map(|v| v.to_string()).collect(),
3973 }
3974 }
3975
3976 fn part_contract(values: &[&str]) -> VerdictContract {
3977 VerdictContract {
3978 channel: VerdictChannel::Part,
3979 values: values.iter().map(|v| v.to_string()).collect(),
3980 }
3981 }
3982
3983 #[test]
3984 fn contract_with_correct_body_channel_and_value_compiles() {
3985 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
3986 let flow = FlowNode::Seq {
3987 children: vec![
3988 step("gate", "$.verdict"),
3989 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
3990 ],
3991 };
3992 let bp = minimal_bp(agent, flow);
3993 assert!(
3994 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
3995 "a cond addressing the bare step output must match a channel: \"body\" contract"
3996 );
3997 }
3998
3999 #[test]
4000 fn contract_with_correct_part_channel_and_value_compiles() {
4001 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4002 let flow = FlowNode::Seq {
4003 children: vec![
4004 step("gate", "$.gate"),
4005 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4006 ],
4007 };
4008 let bp = minimal_bp(agent, flow);
4009 assert!(
4010 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4011 "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
4012 );
4013 }
4014
4015 #[test]
4016 fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
4017 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4021 let flow = FlowNode::Seq {
4022 children: vec![
4023 step("gate", "$.gate"),
4024 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4025 ],
4026 };
4027 let bp = minimal_bp(agent, flow);
4028 match Compiler::new(registry_with_echo()).compile(&bp) {
4029 Err(CompileError::VerdictChannelMismatch {
4030 where_,
4031 agent,
4032 expected_channel,
4033 actual_shape,
4034 }) => {
4035 assert_eq!(agent, "gate");
4036 assert_eq!(expected_channel, "body");
4037 assert_eq!(actual_shape, "part");
4038 assert!(where_.contains("Branch cond"), "where_: {where_}");
4039 }
4040 Err(other) => {
4041 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4042 }
4043 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4044 }
4045 }
4046
4047 #[test]
4048 fn part_channel_contract_rejects_cond_addressing_bare_output() {
4049 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4052 let flow = FlowNode::Seq {
4053 children: vec![
4054 step("gate", "$.verdict"),
4055 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4056 ],
4057 };
4058 let bp = minimal_bp(agent, flow);
4059 match Compiler::new(registry_with_echo()).compile(&bp) {
4060 Err(CompileError::VerdictChannelMismatch {
4061 agent,
4062 expected_channel,
4063 actual_shape,
4064 ..
4065 }) => {
4066 assert_eq!(agent, "gate");
4067 assert_eq!(expected_channel, "part");
4068 assert_eq!(actual_shape, "body");
4069 }
4070 Err(other) => {
4071 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4072 }
4073 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4074 }
4075 }
4076
4077 #[test]
4078 fn contract_rejects_lit_outside_declared_values() {
4079 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4080 let flow = FlowNode::Seq {
4081 children: vec![
4082 step("gate", "$.verdict"),
4083 branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
4084 ],
4085 };
4086 let bp = minimal_bp(agent, flow);
4087 match Compiler::new(registry_with_echo()).compile(&bp) {
4088 Err(CompileError::VerdictValueNotInContract {
4089 agent,
4090 value,
4091 values,
4092 ..
4093 }) => {
4094 assert_eq!(agent, "gate");
4095 assert_eq!(value, "UNKNOWN");
4096 assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4097 }
4098 Err(other) => {
4099 panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
4100 }
4101 Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
4102 }
4103 }
4104
4105 #[test]
4106 fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
4107 let agent = gate_agent(None);
4108 let flow = FlowNode::Seq {
4109 children: vec![
4110 step("gate", "$.verdict"),
4111 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4112 ],
4113 };
4114 let bp = minimal_bp(agent, flow);
4115 assert!(
4116 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4117 "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
4118 );
4119 }
4120
4121 #[test]
4122 fn in_expr_with_lit_haystack_members_compiles() {
4123 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4124 let cond = Expr::In {
4125 needle: Box::new(Expr::Path {
4126 at: "$.verdict".parse().expect("literal test path"),
4127 }),
4128 haystack: Box::new(Expr::Lit {
4129 value: serde_json::json!(["PASS", "BLOCKED"]),
4130 }),
4131 };
4132 let flow = FlowNode::Seq {
4133 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4134 };
4135 let bp = minimal_bp(agent, flow);
4136 assert!(
4137 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4138 "an `In` haystack whose every Lit is a declared value must compile"
4139 );
4140 }
4141
4142 #[test]
4149 fn strict_mode_rejects_unhandled_declared_value() {
4150 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4151 let flow = FlowNode::Seq {
4152 children: vec![
4153 step("gate", "$.verdict"),
4154 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4155 ],
4156 };
4157 let mut bp = minimal_bp(agent, flow);
4158 bp.metadata.strict_verdict_handling = Some(true);
4159 match Compiler::new(registry_with_echo()).compile(&bp) {
4160 Err(CompileError::VerdictValueUnhandled {
4161 agent,
4162 value,
4163 declared_values,
4164 step_ref,
4165 }) => {
4166 assert_eq!(agent, "gate");
4167 assert_eq!(value, "PASS");
4168 assert_eq!(
4169 declared_values,
4170 vec!["PASS".to_string(), "BLOCKED".to_string()]
4171 );
4172 assert_eq!(step_ref, "gate");
4173 }
4174 Err(other) => {
4175 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4176 }
4177 Ok(_) => panic!(
4178 "expected compile-time rejection for a declared verdict value with no \
4179 downstream handler under strict_verdict_handling=Some(true)"
4180 ),
4181 }
4182 }
4183
4184 #[test]
4191 fn default_mode_permits_unhandled_declared_value() {
4192 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4193 let flow = FlowNode::Seq {
4194 children: vec![
4195 step("gate", "$.verdict"),
4196 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4197 ],
4198 };
4199 let bp = minimal_bp(agent, flow);
4200 assert!(
4202 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4203 "default mode must never reject a Blueprint for unhandled declared values \
4204 (opt-in, back-compat with GH #50)"
4205 );
4206 }
4207
4208 #[test]
4213 fn strict_mode_accepts_all_declared_values_handled() {
4214 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4215 let flow = FlowNode::Seq {
4218 children: vec![
4219 step("gate", "$.verdict"),
4220 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4221 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4222 ],
4223 };
4224 let mut bp = minimal_bp(agent, flow);
4225 bp.metadata.strict_verdict_handling = Some(true);
4226 assert!(
4227 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4228 "strict mode must accept a Blueprint that handles every declared value"
4229 );
4230 }
4231
4232 #[test]
4236 fn strict_mode_accepts_declared_values_covered_by_in_expr() {
4237 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4238 let cond = Expr::In {
4239 needle: Box::new(Expr::Path {
4240 at: "$.verdict".parse().expect("literal test path"),
4241 }),
4242 haystack: Box::new(Expr::Lit {
4243 value: serde_json::json!(["PASS", "BLOCKED"]),
4244 }),
4245 };
4246 let flow = FlowNode::Seq {
4247 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4248 };
4249 let mut bp = minimal_bp(agent, flow);
4250 bp.metadata.strict_verdict_handling = Some(true);
4251 assert!(
4252 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4253 "strict mode must accept an `In` haystack that covers every declared value"
4254 );
4255 }
4256
4257 #[test]
4261 fn strict_mode_rejects_unhandled_part_channel_value() {
4262 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4263 let flow = FlowNode::Seq {
4264 children: vec![
4265 step("gate", "$.gate"),
4266 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4267 ],
4268 };
4269 let mut bp = minimal_bp(agent, flow);
4270 bp.metadata.strict_verdict_handling = Some(true);
4271 match Compiler::new(registry_with_echo()).compile(&bp) {
4272 Err(CompileError::VerdictValueUnhandled {
4273 agent,
4274 value,
4275 step_ref,
4276 ..
4277 }) => {
4278 assert_eq!(agent, "gate");
4279 assert_eq!(value, "PASS");
4280 assert_eq!(step_ref, "gate");
4281 }
4282 Err(other) => {
4283 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4284 }
4285 Ok(_) => panic!(
4286 "expected compile-time rejection for a declared verdict value with no \
4287 downstream handler (part channel) under strict_verdict_handling=Some(true)"
4288 ),
4289 }
4290 }
4291
4292 fn lints(
4298 pairs: &[(&str, mlua_swarm_schema::LintSetting)],
4299 ) -> Option<std::collections::BTreeMap<String, mlua_swarm_schema::LintSetting>> {
4300 Some(
4301 pairs
4302 .iter()
4303 .map(|(key, setting)| ((*key).to_string(), *setting))
4304 .collect(),
4305 )
4306 }
4307
4308 fn bp_with_unhandled_value() -> Blueprint {
4312 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4313 let flow = FlowNode::Seq {
4314 children: vec![
4315 step("gate", "$.verdict"),
4316 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4317 ],
4318 };
4319 minimal_bp(agent, flow)
4320 }
4321
4322 fn named_agent(name: &str, verdict: Option<VerdictContract>) -> AgentDef {
4326 AgentDef {
4327 name: name.to_string(),
4328 ..gate_agent(verdict)
4329 }
4330 }
4331
4332 fn bp_with_two_unhandled_agents() -> Blueprint {
4336 let flow = FlowNode::Seq {
4337 children: vec![
4338 step("researcher", "$.researcher_verdict"),
4339 step("reviewer", "$.reviewer_verdict"),
4340 branch(eq_cond("$.researcher_verdict", "BLOCKED"), noop(), noop()),
4341 branch(eq_cond("$.reviewer_verdict", "BLOCKED"), noop(), noop()),
4342 ],
4343 };
4344 let mut bp = minimal_bp(
4345 named_agent("researcher", Some(body_contract(&["PASS", "BLOCKED"]))),
4346 flow,
4347 );
4348 bp.agents.push(named_agent(
4349 "reviewer",
4350 Some(body_contract(&["PASS", "BLOCKED"])),
4351 ));
4352 bp
4353 }
4354
4355 #[test]
4360 fn agent_lints_deny_rejects_only_the_declaring_agent() {
4361 let mut bp = bp_with_two_unhandled_agents();
4362 bp.agents[0].lints = lints(&[(
4363 "verdict-value-unhandled",
4364 mlua_swarm_schema::LintSetting::Deny,
4365 )]);
4366 match Compiler::new(registry_with_echo()).compile(&bp) {
4367 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4368 assert_eq!(agent, "researcher", "the sibling only warns");
4369 assert_eq!(value, "PASS");
4370 }
4371 Err(other) => {
4372 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4373 }
4374 Ok(_) => panic!(
4375 "expected compile-time rejection under \
4376 agents[0].lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4377 ),
4378 }
4379 }
4380
4381 #[test]
4385 fn agent_allow_beats_blueprint_deny_for_that_agent() {
4386 let mut bp = bp_with_two_unhandled_agents();
4387 bp.metadata.lints = lints(&[(
4388 "verdict-value-unhandled",
4389 mlua_swarm_schema::LintSetting::Deny,
4390 )]);
4391 bp.agents[0].lints = lints(&[(
4392 "verdict-value-unhandled",
4393 mlua_swarm_schema::LintSetting::Allow,
4394 )]);
4395 match Compiler::new(registry_with_echo()).compile(&bp) {
4396 Err(CompileError::VerdictValueUnhandled { agent, .. }) => {
4397 assert_eq!(
4398 agent, "reviewer",
4399 "the allowing agent is silenced; the sibling still denies"
4400 );
4401 }
4402 Err(other) => {
4403 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4404 }
4405 Ok(_) => panic!("the sibling agent's Blueprint-level deny must still reject"),
4406 }
4407 }
4408
4409 #[test]
4413 fn strict_flag_wins_over_agent_lints_allow() {
4414 let mut bp = bp_with_unhandled_value();
4415 bp.metadata.strict_verdict_handling = Some(true);
4416 bp.agents[0].lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4417 match Compiler::new(registry_with_echo()).compile(&bp) {
4418 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(agent, "gate"),
4419 Err(other) => {
4420 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4421 }
4422 Ok(_) => panic!(
4423 "strict_verdict_handling=Some(true) must still reject under an agent-level allow"
4424 ),
4425 }
4426 }
4427
4428 #[test]
4431 fn agent_category_key_reaches_the_kind() {
4432 let mut bp = bp_with_two_unhandled_agents();
4433 bp.agents[0].lints =
4434 lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4435 match Compiler::new(registry_with_echo()).compile(&bp) {
4436 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(
4437 agent, "researcher",
4438 "a category: group deny must reach the kind it covers, on the declaring agent"
4439 ),
4440 Err(other) => {
4441 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4442 }
4443 Ok(_) => panic!("expected compile-time rejection under an agent-level category deny"),
4444 }
4445 }
4446
4447 #[test]
4450 fn agent_without_lints_inherits_the_blueprint_layer() {
4451 let mut bp = bp_with_two_unhandled_agents();
4452 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4453 assert!(
4454 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4455 "a Blueprint-wide allow covers every agent that declares nothing"
4456 );
4457
4458 let gates = resolve_unhandled_verdict_gates(&bp);
4459 assert_eq!(gates.for_agent("reviewer"), UnhandledVerdictGate::Silence);
4460 assert!(gates.all_silent());
4461
4462 bp.agents[0].lints = lints(&[(
4463 "verdict-value-unhandled",
4464 mlua_swarm_schema::LintSetting::Warn,
4465 )]);
4466 let gates = resolve_unhandled_verdict_gates(&bp);
4467 assert_eq!(
4468 gates.for_agent("researcher"),
4469 UnhandledVerdictGate::Warn,
4470 "the agent's own layer wins over the Blueprint's allow"
4471 );
4472 assert_eq!(
4473 gates.for_agent("reviewer"),
4474 UnhandledVerdictGate::Silence,
4475 "the sibling keeps the Blueprint layer"
4476 );
4477 assert!(!gates.all_silent());
4478 }
4479
4480 #[test]
4484 fn lints_deny_rejects_unhandled_declared_value() {
4485 let mut bp = bp_with_unhandled_value();
4486 bp.metadata.lints = lints(&[(
4487 "verdict-value-unhandled",
4488 mlua_swarm_schema::LintSetting::Deny,
4489 )]);
4490 match Compiler::new(registry_with_echo()).compile(&bp) {
4491 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4492 assert_eq!(agent, "gate");
4493 assert_eq!(value, "PASS");
4494 }
4495 Err(other) => {
4496 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4497 }
4498 Ok(_) => panic!(
4499 "expected compile-time rejection under \
4500 metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4501 ),
4502 }
4503 }
4504
4505 #[test]
4508 fn lints_category_deny_rejects_unhandled_declared_value() {
4509 let mut bp = bp_with_unhandled_value();
4510 bp.metadata.lints = lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4511 assert!(
4512 matches!(
4513 Compiler::new(registry_with_echo()).compile(&bp),
4514 Err(CompileError::VerdictValueUnhandled { .. })
4515 ),
4516 "a category: group deny must reach the kind it covers"
4517 );
4518 }
4519
4520 #[test]
4524 fn lints_allow_compiles_and_silences_the_warn() {
4525 let mut bp = bp_with_unhandled_value();
4526 bp.metadata.lints = lints(&[(
4527 "verdict-value-unhandled",
4528 mlua_swarm_schema::LintSetting::Allow,
4529 )]);
4530 assert!(
4531 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4532 "an allowed lint must never reject the compile"
4533 );
4534 assert_eq!(
4535 resolve_unhandled_verdict_gate(&bp.metadata),
4536 UnhandledVerdictGate::Silence
4537 );
4538 }
4539
4540 #[test]
4544 fn strict_flag_wins_over_lints_allow() {
4545 let mut bp = bp_with_unhandled_value();
4546 bp.metadata.strict_verdict_handling = Some(true);
4547 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4548 assert!(
4549 matches!(
4550 Compiler::new(registry_with_echo()).compile(&bp),
4551 Err(CompileError::VerdictValueUnhandled { .. })
4552 ),
4553 "strict_verdict_handling=Some(true) must still reject under a lints allow"
4554 );
4555 }
4556
4557 #[test]
4560 fn unhandled_verdict_gate_resolution_table() {
4561 use mlua_swarm_schema::LintSetting;
4562
4563 let gate = |strict, map| {
4564 resolve_unhandled_verdict_gate(&BlueprintMetadata {
4565 strict_verdict_handling: strict,
4566 lints: map,
4567 ..Default::default()
4568 })
4569 };
4570 let kind = "verdict-value-unhandled";
4571
4572 assert_eq!(gate(None, None), UnhandledVerdictGate::Warn);
4573 assert_eq!(gate(Some(false), None), UnhandledVerdictGate::Warn);
4574 assert_eq!(gate(Some(true), None), UnhandledVerdictGate::Deny);
4575 assert_eq!(
4576 gate(None, lints(&[(kind, LintSetting::Deny)])),
4577 UnhandledVerdictGate::Deny
4578 );
4579 assert_eq!(
4580 gate(None, lints(&[(kind, LintSetting::Warn)])),
4581 UnhandledVerdictGate::Warn
4582 );
4583 assert_eq!(
4584 gate(None, lints(&[(kind, LintSetting::Allow)])),
4585 UnhandledVerdictGate::Silence
4586 );
4587 assert_eq!(
4588 gate(Some(true), lints(&[(kind, LintSetting::Allow)])),
4589 UnhandledVerdictGate::Deny,
4590 "strict wins over allow"
4591 );
4592 assert_eq!(
4594 gate(
4595 None,
4596 lints(&[
4597 (kind, LintSetting::Allow),
4598 ("category:suspicious", LintSetting::Deny),
4599 ])
4600 ),
4601 UnhandledVerdictGate::Silence
4602 );
4603 assert_eq!(
4606 gate(None, lints(&[("no-such-lint", LintSetting::Deny)])),
4607 UnhandledVerdictGate::Warn
4608 );
4609 }
4610
4611 #[test]
4615 fn lints_never_soften_other_compile_errors() {
4616 let mut bp = bp_with_unhandled_value();
4617 bp.agents.push(gate_agent(None));
4618 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4619 assert!(
4620 matches!(
4621 Compiler::new(registry_with_echo()).compile(&bp),
4622 Err(CompileError::DuplicateAgent(name)) if name == "gate"
4623 ),
4624 "an `all` allow must not suppress a compile hard error"
4625 );
4626 }
4627
4628 #[test]
4635 fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
4636 let agent = gate_agent(None);
4637 let flow = FlowNode::Seq {
4638 children: vec![
4639 step("gate", "$.verdict"),
4640 FlowNode::Loop {
4641 counter: Expr::Path {
4642 at: "$.n".parse().expect("literal test path"),
4643 },
4644 cond: eq_cond("$.verdict", "BLOCKED"),
4645 body: Box::new(step("gate", "$.verdict")),
4646 max: 3,
4647 },
4648 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4649 ],
4650 };
4651 let bp = minimal_bp(agent, flow);
4652 let compiled = Compiler::new(registry_with_echo())
4653 .compile(&bp)
4654 .expect("a verdict-omitted Blueprint must compile unchanged");
4655 assert!(
4656 compiled.router.verdict_contracts.is_empty(),
4657 "no agent declared a verdict contract"
4658 );
4659 }
4660
4661 #[test]
4668 fn every_compile_error_diagnostic_kind_is_a_declared_lint() {
4669 let kinds = [
4670 "bound-agent-resolution",
4671 "unknown-agent-kind",
4672 "invalid-agent-spec",
4673 "worker-binding-missing",
4674 "unresolved-agent-ref",
4675 "duplicate-agent-name",
4676 "unresolved-operator-ref",
4677 "unresolved-meta-ref",
4678 "step-naming-collision",
4679 "invalid-projection-placement",
4680 "unresolved-audit-agent",
4681 "verdict-channel-mismatch",
4682 "verdict-value-not-in-contract",
4683 "verdict-value-unhandled",
4684 "removed-spawner-hint",
4685 ];
4686 for kind in kinds {
4687 assert!(
4688 mlua_swarm_diag::lint_decl(kind).is_some(),
4689 "kind '{kind}' emitted by From<&CompileError> has no LINT_DECLS entry"
4690 );
4691 }
4692 }
4693
4694 #[test]
4695 fn invalid_spec_with_worker_binding_prefix_specializes_the_diagnostic_kind() {
4696 let err = CompileError::InvalidSpec {
4700 name: "greeter".into(),
4701 msg: format!("{WORKER_BINDING_REQUIRED_MSG_PREFIX}. Fix by either: (a) ..."),
4702 };
4703 let d = mlua_swarm_diag::Diagnostic::from(&err);
4704 assert_eq!(d.kind, "worker-binding-missing");
4705 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4706 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4707 assert!(d.message.contains("greeter"));
4708 let suggestion = d
4709 .suggestion
4710 .expect("specialized arm must carry a suggestion");
4711 assert!(suggestion.patch.contains("backend = \"ws_operator\""));
4712 assert_eq!(
4713 suggestion.applicability,
4714 mlua_swarm_diag::Applicability::HasPlaceholders
4715 );
4716 assert_eq!(
4717 d.docs_ref.expect("docs_ref must be set").uri,
4718 "mse://guides/bp-dsl-templates"
4719 );
4720 match d.span.expect("span must be set").element {
4721 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "greeter"),
4722 other => panic!("expected Agent span, got {other:?}"),
4723 }
4724 }
4725
4726 fn bp_with_hint_layers(layers: &[&str]) -> Blueprint {
4736 let mut bp = minimal_bp(gate_agent(None), step("gate", "$.verdict"));
4737 bp.spawner_hints = mlua_swarm_schema::SpawnerHints {
4738 layers: layers.iter().map(|s| s.to_string()).collect(),
4739 };
4740 bp
4741 }
4742
4743 #[test]
4744 fn declaring_the_removed_operator_delegate_layer_fails_the_compile() {
4745 let bp = bp_with_hint_layers(&["operator_delegate"]);
4746 match Compiler::new(registry_with_echo()).compile(&bp) {
4747 Err(CompileError::RemovedSpawnerHint { key, reason }) => {
4748 assert_eq!(key, "operator_delegate");
4749 assert!(
4750 reason.contains("Run.current") && reason.contains("system"),
4751 "the reason must name both defects the removal was for, so the error \
4752 alone explains why: {reason}"
4753 );
4754 }
4755 Err(other) => panic!("expected RemovedSpawnerHint, got: {other}"),
4756 Ok(_) => panic!(
4757 "a Blueprint declaring the removed operator_delegate layer must not compile — \
4758 otherwise the layer silently stops applying"
4759 ),
4760 }
4761 }
4762
4763 #[test]
4769 fn an_unknown_but_not_removed_layer_key_still_compiles() {
4770 let bp = bp_with_hint_layers(&["main_ai", "some_deployment_specific_layer"]);
4771 Compiler::new(registry_with_echo())
4772 .compile(&bp)
4773 .expect("unknown (as opposed to removed) hint keys stay leniently skipped");
4774 }
4775
4776 #[test]
4777 fn a_blueprint_declaring_no_layers_compiles() {
4778 let bp = bp_with_hint_layers(&[]);
4779 Compiler::new(registry_with_echo())
4780 .compile(&bp)
4781 .expect("the all-clear case must produce no finding at all");
4782 }
4783
4784 #[test]
4785 fn removed_spawner_hint_projects_a_migration_diagnostic_naming_the_replacement() {
4786 let err = CompileError::RemovedSpawnerHint {
4787 key: "operator_delegate".into(),
4788 reason: "it could not follow a handover".into(),
4789 };
4790 let d = mlua_swarm_diag::Diagnostic::from(&err);
4791
4792 assert_eq!(d.kind, "removed-spawner-hint");
4793 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4794 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4795
4796 let decl = mlua_swarm_diag::lint_decl("removed-spawner-hint")
4800 .expect("the kind must be declared in LINT_DECLS");
4801 assert_eq!(decl.category, mlua_swarm_diag::LintCategory::Migration);
4802
4803 let help = d.help.as_ref().expect("help must name the replacement");
4805 assert!(
4806 help.contains("operators[]")
4807 && help.contains("spec.operator_ref")
4808 && help.contains("operator_sid"),
4809 "the help line must name all three parts of the AgentSpec axis an author has to \
4810 write, not just say the old one is gone: {help}"
4811 );
4812
4813 let suggestion = d.suggestion.expect("a concrete patch must be attached");
4821 assert_eq!(
4822 suggestion,
4823 mlua_swarm_diag::removed_spawner_hint_suggestion()
4824 );
4825 assert!(
4826 suggestion.patch.contains("\"operator_ref\""),
4827 "whatever else the shared patch says, it has to show the field an author \
4828 must add: {}",
4829 suggestion.patch
4830 );
4831 assert_eq!(
4832 suggestion.applicability,
4833 mlua_swarm_diag::Applicability::HasPlaceholders
4836 );
4837
4838 assert_eq!(
4839 d.docs_ref.expect("docs_ref must be set").uri,
4840 "mse://guides/blueprint-authoring"
4841 );
4842 assert!(matches!(
4843 d.span.expect("span must be set").element,
4844 mlua_swarm_diag::DiagElement::BlueprintRoot
4845 ));
4846 }
4847
4848 #[test]
4849 fn generic_invalid_spec_maps_to_the_generic_kind() {
4850 let err = CompileError::InvalidSpec {
4851 name: "solo".into(),
4852 msg: "operator spec: 'operator_ref' (string) required".into(),
4853 };
4854 let d = mlua_swarm_diag::Diagnostic::from(&err);
4855 assert_eq!(d.kind, "invalid-agent-spec");
4856 assert!(
4857 d.suggestion.is_none(),
4858 "generic arm carries no canned patch"
4859 );
4860 }
4861
4862 #[test]
4863 fn verdict_value_not_in_contract_diagnostic_carries_suggestion_and_span() {
4864 let err = CompileError::VerdictValueNotInContract {
4865 where_: "Branch cond".into(),
4866 agent: "review".into(),
4867 value: "NOT_DECLARED".into(),
4868 values: vec!["PASS".into(), "BLOCKED".into()],
4869 };
4870 let d = mlua_swarm_diag::Diagnostic::from(&err);
4871 assert_eq!(d.kind, "verdict-value-not-in-contract");
4872 assert!(d.message.contains("NOT_DECLARED"));
4873 assert!(d.suggestion.is_some());
4874 match d.span.expect("span must be set").element {
4875 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "review"),
4876 other => panic!("expected Agent span, got {other:?}"),
4877 }
4878 }
4879}
4880
4881#[cfg(test)]
4883mod subprocess_embed_compile_tests {
4884 use super::*;
4885 use mlua_swarm_schema::{current_schema_version, SubprocessDef, SubprocessOverrides};
4886
4887 fn subprocess_agent(name: &str, runner: Option<Runner>) -> AgentDef {
4888 AgentDef {
4889 name: name.to_string(),
4890 kind: AgentKind::Subprocess,
4891 spec: serde_json::json!({}),
4892 profile: Some(AgentProfile {
4893 system_prompt: "you are a headless worker".to_string(),
4894 model: Some("profile-model".to_string()),
4895 tools: vec!["Read".to_string()],
4896 ..Default::default()
4897 }),
4898 meta: None,
4899 runner,
4900 runner_ref: None,
4901 verdict: None,
4902 lints: None,
4903 }
4904 }
4905
4906 fn echo_def(name: &str) -> SubprocessDef {
4907 SubprocessDef {
4908 name: name.to_string(),
4909 argv: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
4910 stdin: Some("{prompt}".to_string()),
4911 env: Default::default(),
4912 cwd: None,
4913 output: None,
4914 stream_mode: None,
4915 timeout_ms: None,
4916 }
4917 }
4918
4919 fn bp_with(agents: Vec<AgentDef>, subprocesses: Vec<SubprocessDef>) -> Blueprint {
4920 Blueprint {
4921 schema_version: current_schema_version(),
4922 id: "gh83-ut".into(),
4923 flow: FlowNode::Seq { children: vec![] },
4924 agents,
4925 operators: vec![],
4926 metas: vec![],
4927 hints: Default::default(),
4928 strategy: Default::default(),
4929 metadata: BlueprintMetadata::default(),
4930 spawner_hints: Default::default(),
4931 default_agent_kind: AgentKind::Operator,
4932 default_operator_kind: None,
4933 default_init_ctx: None,
4934 default_agent_ctx: None,
4935 default_context_policy: None,
4936 projection_placement: None,
4937 audits: vec![],
4938 degradation_policy: None,
4939 runners: vec![],
4940 default_runner: None,
4941 subprocesses,
4942 check_policy: None,
4943 blueprint_ref_includes: vec![],
4944 }
4945 }
4946
4947 fn subprocess_runner(template: &str) -> Runner {
4948 Runner::Subprocess {
4949 template: template.to_string(),
4950 overrides: SubprocessOverrides::default(),
4951 }
4952 }
4953
4954 #[test]
4955 fn validate_placeholders_accepts_closed_set_and_json_braces() {
4956 for ok in [
4957 "{system} {system_file} {prompt} {model} {tools_csv} {work_dir} {task_id} {attempt}",
4958 r#"echo '{"result": "ok", "nested": {"a": 1}}'"#,
4959 "no placeholders at all",
4960 "unmatched { brace",
4961 ] {
4962 validate_embed_placeholders(ok, "ut").expect("must be accepted");
4963 }
4964 }
4965
4966 #[test]
4967 fn validate_placeholders_rejects_unknown_token() {
4968 let err = validate_embed_placeholders("--flag {evil}", "argv[1]").unwrap_err();
4969 assert!(err.contains("'{evil}'"), "token named: {err}");
4970 assert!(err.contains("closed set"), "closed set listed: {err}");
4971 }
4972
4973 #[test]
4977 fn validate_placeholders_descends_into_literal_braces() {
4978 validate_embed_placeholders(r#"{"task": "{prompt}"}"#, "stdin")
4979 .expect("nested closed-set token must be accepted");
4980 let err = validate_embed_placeholders(r#"{"task": "{evil}"}"#, "stdin").unwrap_err();
4981 assert!(
4982 err.contains("'{evil}'"),
4983 "nested unknown token caught: {err}"
4984 );
4985 }
4986
4987 #[test]
4988 fn hint_resolution_finds_declared_template() {
4989 let agent = subprocess_agent("headless", Some(subprocess_runner("echo")));
4990 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4991 let hint = resolve_subprocess_template_hint(&bp, &agent)
4992 .expect("resolves")
4993 .expect("Runner::Subprocess must synthesize a hint");
4994 assert_eq!(hint[SUBPROCESS_TEMPLATE_HINT_KEY]["name"], "echo");
4995 assert!(hint.get(SUBPROCESS_OVERRIDES_HINT_KEY).is_some());
4996 }
4997
4998 #[test]
4999 fn hint_resolution_unknown_template_is_invalid_spec() {
5000 let agent = subprocess_agent("headless", Some(subprocess_runner("nope")));
5001 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5002 let err = resolve_subprocess_template_hint(&bp, &agent).unwrap_err();
5003 let msg = format!("{err}");
5004 assert!(msg.contains("'nope'"), "missing template named: {msg}");
5005 assert!(msg.contains("echo"), "defined templates listed: {msg}");
5006 }
5007
5008 #[test]
5009 fn hint_resolution_none_without_subprocess_runner() {
5010 let agent = subprocess_agent("headless", None);
5011 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5012 let hint = resolve_subprocess_template_hint(&bp, &agent).expect("resolves");
5013 assert!(hint.is_none(), "spec-based agents keep the historical path");
5014 }
5015
5016 fn agent_block_agent(name: &str, runner: Option<Runner>, profile_tools: &[&str]) -> AgentDef {
5023 AgentDef {
5024 name: name.to_string(),
5025 kind: AgentKind::AgentBlock,
5026 spec: serde_json::json!({}),
5027 profile: Some(AgentProfile {
5028 system_prompt: "you are an in-process auditor".to_string(),
5029 tools: profile_tools.iter().map(|t| t.to_string()).collect(),
5030 ..Default::default()
5031 }),
5032 meta: None,
5033 runner,
5034 runner_ref: None,
5035 verdict: None,
5036 lints: None,
5037 }
5038 }
5039
5040 fn agent_block_runner(tools: &[&str]) -> Runner {
5041 Runner::AgentBlockInProcess {
5042 tools: tools.iter().map(|t| t.to_string()).collect(),
5043 }
5044 }
5045
5046 #[test]
5052 fn agent_block_runner_tools_are_projected_over_profile_tools() {
5053 let agent = agent_block_agent(
5054 "auditor",
5055 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5056 &["Read"],
5057 );
5058 let bp = bp_with(vec![agent], vec![]);
5059 let bound = resolve_bound_agents(&bp).expect("binds");
5060 let effective = materialize_bound_blueprint(&bp, &bound);
5061 assert_eq!(
5062 effective.agents[0].profile.as_ref().unwrap().tools,
5063 vec!["mcp__outline__list_docs".to_string()],
5064 "the declared Runner tools replace profile.tools (['Read'])"
5065 );
5066 }
5067
5068 #[test]
5072 fn agent_block_projection_distinguishes_declared_empty_from_absent() {
5073 let declared = agent_block_agent("auditor", Some(agent_block_runner(&[])), &["Read"]);
5074 let bp = bp_with(vec![declared], vec![]);
5075 let bound = resolve_bound_agents(&bp).expect("binds");
5076 let effective = materialize_bound_blueprint(&bp, &bound);
5077 assert!(
5078 effective.agents[0]
5079 .profile
5080 .as_ref()
5081 .unwrap()
5082 .tools
5083 .is_empty(),
5084 "empty means enforced-empty, not 'unset'"
5085 );
5086
5087 let absent = agent_block_agent("auditor", None, &["Read"]);
5088 let bp = bp_with(vec![absent], vec![]);
5089 let bound = resolve_bound_agents(&bp).expect("binds");
5090 let effective = materialize_bound_blueprint(&bp, &bound);
5091 assert_eq!(
5092 effective.agents[0].profile.as_ref().unwrap().tools,
5093 vec!["Read".to_string()],
5094 "no Runner declared → the agent.md tools line stands"
5095 );
5096 }
5097
5098 #[test]
5105 fn compile_rejects_script_mode_with_a_declared_mcp_grant() {
5106 let mut agent = agent_block_agent(
5107 "auditor",
5108 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5109 &[],
5110 );
5111 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5112 let mut bp = bp_with(vec![agent], vec![]);
5113 bp.strategy.strict_refs = false;
5114
5115 let mut registry = SpawnerRegistry::new();
5116 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5117 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5118 );
5119 let err = match Compiler::new(registry).compile(&bp) {
5121 Err(e) => e,
5122 Ok(_) => panic!("script mode + declared MCP grant must not compile"),
5123 };
5124 let msg = format!("{err}");
5125 assert!(msg.contains("script_path"), "names the trigger: {msg}");
5126 assert!(
5127 msg.contains("mcp__outline__list_docs"),
5128 "names the unenforceable tools: {msg}"
5129 );
5130 }
5131
5132 #[test]
5136 fn compile_accepts_script_mode_with_only_inert_tools() {
5137 let mut agent = agent_block_agent("auditor", None, &["Read", "WebSearch"]);
5138 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5139 let mut bp = bp_with(vec![agent], vec![]);
5140 bp.strategy.strict_refs = false;
5141
5142 let mut registry = SpawnerRegistry::new();
5143 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5144 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5145 );
5146 if let Err(e) = Compiler::new(registry).compile(&bp) {
5147 panic!("inert tools must not trip the MCP-grant guard: {e}");
5148 }
5149 }
5150
5151 #[test]
5152 fn build_embed_rejects_unknown_placeholder() {
5153 let agent = subprocess_agent("headless", None);
5154 let mut def = echo_def("echo");
5155 def.argv.push("--x={evil}".to_string());
5156 let err = SubprocessProcessSpawnerFactory::build_embed(
5157 &agent,
5158 &serde_json::to_value(&def).unwrap(),
5159 None,
5160 )
5161 .unwrap_err();
5162 assert!(format!("{err}").contains("'{evil}'"));
5163 }
5164
5165 #[test]
5166 fn build_embed_rejects_output_with_stream_mode() {
5167 let agent = subprocess_agent("headless", None);
5168 let mut def = echo_def("echo");
5169 def.stream_mode = Some("ndjson_lines".to_string());
5170 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5171 format: Some("json".to_string()),
5172 result_ptr: None,
5173 ok_from: None,
5174 stats: None,
5175 });
5176 let err = SubprocessProcessSpawnerFactory::build_embed(
5177 &agent,
5178 &serde_json::to_value(&def).unwrap(),
5179 None,
5180 )
5181 .unwrap_err();
5182 assert!(format!("{err}").contains("plain-mode"));
5183 }
5184
5185 #[test]
5186 fn build_embed_rejects_malformed_result_ptr_and_ok_from() {
5187 let agent = subprocess_agent("headless", None);
5188 let mut def = echo_def("echo");
5189 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5190 format: None,
5191 result_ptr: Some("result".to_string()),
5192 ok_from: None,
5193 stats: None,
5194 });
5195 let err = SubprocessProcessSpawnerFactory::build_embed(
5196 &agent,
5197 &serde_json::to_value(&def).unwrap(),
5198 None,
5199 )
5200 .unwrap_err();
5201 assert!(format!("{err}").contains("JSON Pointer"));
5202
5203 let mut def = echo_def("echo");
5204 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5205 format: None,
5206 result_ptr: None,
5207 ok_from: Some("status".to_string()),
5208 stats: None,
5209 });
5210 let err = SubprocessProcessSpawnerFactory::build_embed(
5211 &agent,
5212 &serde_json::to_value(&def).unwrap(),
5213 None,
5214 )
5215 .unwrap_err();
5216 assert!(format!("{err}").contains("exit_code"));
5217 }
5218
5219 #[test]
5220 fn build_embed_bakes_profile_with_override_precedence() {
5221 let agent = subprocess_agent("headless", None);
5222 let def = echo_def("echo");
5223 let overrides = SubprocessOverrides {
5224 model: Some("override-model".to_string()),
5225 tools: vec!["Bash".to_string(), "Write".to_string()],
5226 cwd: Some("/tmp/override-wd".to_string()),
5227 };
5228 let sp = SubprocessProcessSpawnerFactory::build_embed(
5229 &agent,
5230 &serde_json::to_value(&def).unwrap(),
5231 Some(&serde_json::to_value(&overrides).unwrap()),
5232 )
5233 .expect("builds");
5234 let embed = sp.embed.as_ref().expect("embed template baked");
5235 assert_eq!(embed.model.as_deref(), Some("override-model"));
5236 assert_eq!(embed.tools_csv, "Bash,Write");
5237 assert_eq!(embed.cwd.as_deref(), Some("/tmp/override-wd"));
5238 assert_eq!(
5239 embed.system_prompt.as_deref(),
5240 Some("you are a headless worker")
5241 );
5242 }
5243}