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::Assign { .. } => {} }
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::Assign { .. } => {} }
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::Assign { .. } => {} }
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::Assign { .. } => {}
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 let Some(output) = &def.output {
1958 if stream_mode.is_some() {
1959 return Err(invalid(format!(
1960 "SubprocessDef '{}': output normalization is a plain-mode \
1961 declaration; remove either `output` or `stream_mode`",
1962 def.name
1963 )));
1964 }
1965 if let Some(format) = output.format.as_deref() {
1966 if format != "json" {
1967 return Err(invalid(format!(
1968 "SubprocessDef '{}': unknown output.format '{format}' \
1969 (supported: \"json\")",
1970 def.name
1971 )));
1972 }
1973 }
1974 if let Some(ptr) = output.result_ptr.as_deref() {
1975 if !ptr.starts_with('/') {
1976 return Err(invalid(format!(
1977 "SubprocessDef '{}': output.result_ptr '{ptr}' is not a \
1978 JSON Pointer (RFC 6901 — must start with '/')",
1979 def.name
1980 )));
1981 }
1982 }
1983 if let Some(ok_from) = output.ok_from.as_deref() {
1984 if ok_from != "exit_code" && !ok_from.starts_with('/') {
1985 return Err(invalid(format!(
1986 "SubprocessDef '{}': output.ok_from '{ok_from}' must be \
1987 \"exit_code\" or a JSON Pointer (starting with '/')",
1988 def.name
1989 )));
1990 }
1991 }
1992 }
1993
1994 let profile = agent_def.profile.as_ref();
1997 let system_prompt = profile
1998 .map(|p| p.system_prompt.clone())
1999 .filter(|s| !s.is_empty());
2000 let model = overrides
2001 .model
2002 .clone()
2003 .or_else(|| profile.and_then(|p| p.model.clone()));
2004 let tools: Vec<String> = if overrides.tools.is_empty() {
2005 profile.map(|p| p.tools.clone()).unwrap_or_default()
2006 } else {
2007 overrides.tools.clone()
2008 };
2009 let cwd = overrides.cwd.clone().or_else(|| def.cwd.clone());
2011 if let Some(c) = &cwd {
2012 validate_embed_placeholders(c, "overrides.cwd").map_err(&invalid)?;
2013 }
2014
2015 let program = def.argv[0].clone();
2016 let sp = ProcessSpawner {
2017 program,
2018 args: Vec::new(),
2019 use_stdin: def.stdin.is_some(),
2020 stream_mode,
2021 embed: Some(EmbedTemplate {
2022 argv: def.argv,
2023 stdin: def.stdin,
2024 env: def.env,
2025 cwd,
2026 output: def.output,
2027 system_prompt,
2028 model,
2029 tools_csv: tools.join(","),
2030 }),
2031 };
2032 Ok(sp)
2033 }
2034}
2035
2036impl SpawnerFactory for SubprocessProcessSpawnerFactory {
2037 fn build(
2038 &self,
2039 agent_def: &AgentDef,
2040 hint: Option<&Value>,
2041 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2042 if let Some(template) = hint.and_then(|h| h.get(SUBPROCESS_TEMPLATE_HINT_KEY)) {
2046 let overrides = hint.and_then(|h| h.get(SUBPROCESS_OVERRIDES_HINT_KEY));
2047 return Self::build_embed(agent_def, template, overrides).map(|sp| {
2048 let arc: Arc<dyn SpawnerAdapter> = Arc::new(sp);
2049 arc
2050 });
2051 }
2052 let agent_name = &agent_def.name;
2053 let spec = &agent_def.spec;
2054 let invalid = |msg: String| CompileError::InvalidSpec {
2055 name: agent_name.to_string(),
2056 msg,
2057 };
2058 let program = spec
2059 .get("program")
2060 .and_then(|v| v.as_str())
2061 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
2062 .to_string();
2063 let args: Vec<String> = spec
2064 .get("args")
2065 .and_then(|v| v.as_array())
2066 .map(|a| {
2067 a.iter()
2068 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2069 .collect()
2070 })
2071 .unwrap_or_default();
2072 let use_stdin = spec
2073 .get("use_stdin")
2074 .and_then(|v| v.as_bool())
2075 .unwrap_or(true);
2076 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
2077 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
2078 Some("sse_events") => Some(StreamMode::SseEvents),
2079 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
2080 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
2081 None => None,
2082 };
2083
2084 let mut sp = ProcessSpawner {
2085 program,
2086 args,
2087 use_stdin,
2088 stream_mode,
2089 embed: None,
2090 };
2091 if let Some(mode) = sp.stream_mode.clone() {
2092 sp = sp.stream_mode(mode);
2093 }
2094 Ok(Arc::new(sp))
2095 }
2096}
2097
2098pub struct LuaInProcessSpawnerFactory {
2129 registry: HashMap<String, WorkerFn>,
2130 bridges: HashMap<String, HostBridge>,
2131}
2132
2133#[derive(Clone)]
2145pub struct HostBridge(
2146 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
2147);
2148
2149impl HostBridge {
2150 pub fn new<F>(f: F) -> Self
2152 where
2153 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
2154 {
2155 Self(Arc::new(f))
2156 }
2157
2158 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
2162 (self.0)(arg)
2163 }
2164}
2165
2166#[derive(Clone)]
2173pub struct LuaScriptSource {
2174 pub source: String,
2176 pub label: String,
2179}
2180
2181impl LuaScriptSource {
2182 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
2184 Self {
2185 source: source.into(),
2186 label: label.into(),
2187 }
2188 }
2189}
2190
2191impl LuaInProcessSpawnerFactory {
2192 pub fn new() -> Self {
2194 Self {
2195 registry: HashMap::new(),
2196 bridges: HashMap::new(),
2197 }
2198 }
2199
2200 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
2207 self.bridges.insert(name.into(), bridge);
2208 self
2209 }
2210
2211 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
2229 let source = Arc::new(source);
2230 let bridges = Arc::new(self.bridges.clone());
2231 let wrapped: WorkerFn = Arc::new(move |inv| {
2232 let source = source.clone();
2233 let bridges = bridges.clone();
2234 Box::pin(run_lua_worker(source, bridges, inv))
2235 });
2236 self.registry.insert(fn_id.into(), wrapped);
2237 self
2238 }
2239}
2240
2241async fn run_lua_worker(
2243 source: Arc<LuaScriptSource>,
2244 bridges: Arc<HashMap<String, HostBridge>>,
2245 inv: crate::worker::adapter::WorkerInvocation,
2246) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
2247 use crate::worker::adapter::WorkerError;
2248 use mlua::LuaSerdeExt;
2249
2250 let label = source.label.clone();
2251 let outcome =
2252 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
2253 let lua = mlua::Lua::new();
2254 let g = lua.globals();
2255
2256 g.set("_PROMPT", inv.prompt.clone())
2258 .map_err(|e| format!("set _PROMPT: {e}"))?;
2259 g.set("_AGENT", inv.agent.clone())
2260 .map_err(|e| format!("set _AGENT: {e}"))?;
2261 g.set("_TASK_ID", inv.task_id.to_string())
2262 .map_err(|e| format!("set _TASK_ID: {e}"))?;
2263 g.set("_ATTEMPT", inv.attempt as i64)
2264 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
2265
2266 for (name, value) in
2275 crate::worker::agent_block::runtime::context_globals(inv.context.as_ref())
2276 {
2277 let lua_val = lua
2278 .to_value(&value)
2279 .map_err(|e| format!("{name} to_value: {e}"))?;
2280 g.set(name.as_str(), lua_val)
2281 .map_err(|e| format!("set {name}: {e}"))?;
2282 }
2283
2284 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
2286 let lua_val = lua
2287 .to_value(&json_val)
2288 .map_err(|e| format!("_CTX to_value: {e}"))?;
2289 g.set("_CTX", lua_val)
2290 .map_err(|e| format!("set _CTX: {e}"))?;
2291 }
2292
2293 if !bridges.is_empty() {
2295 let host = lua
2296 .create_table()
2297 .map_err(|e| format!("create host table: {e}"))?;
2298 for (name, bridge) in bridges.iter() {
2299 let bridge = bridge.clone();
2300 let bname = name.clone();
2301 let f = lua
2302 .create_function(move |lua, arg: mlua::Value| {
2303 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
2304 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
2305 })?;
2306 let result_json =
2307 bridge.call(json_arg).map_err(mlua::Error::external)?;
2308 lua.to_value(&result_json).map_err(|e| {
2309 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
2310 })
2311 })
2312 .map_err(|e| format!("create_function {name}: {e}"))?;
2313 host.set(name.as_str(), f)
2314 .map_err(|e| format!("host.{name} set: {e}"))?;
2315 }
2316 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
2317 }
2318
2319 let result: mlua::Value = lua
2321 .load(&source.source)
2322 .set_name(&source.label)
2323 .eval()
2324 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
2325
2326 let json_result: serde_json::Value = lua
2328 .from_value(result)
2329 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
2330
2331 let (value, ok) = match &json_result {
2332 serde_json::Value::Object(map)
2333 if map.contains_key("value") || map.contains_key("ok") =>
2334 {
2335 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
2336 let value = map.get("value").cloned().unwrap_or(json_result.clone());
2337 (value, ok)
2338 }
2339 _ => (json_result, true),
2340 };
2341 Ok((value, ok))
2342 })
2343 .await
2344 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
2345 .map_err(WorkerError::Failed)?;
2346
2347 Ok(crate::worker::adapter::WorkerResult {
2348 value: outcome.0,
2349 ok: outcome.1,
2350 stats: None,
2351 }
2352 .ensure_worker_kind("lua"))
2353}
2354
2355impl Default for LuaInProcessSpawnerFactory {
2356 fn default() -> Self {
2357 Self::new()
2358 }
2359}
2360
2361impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
2362 const KIND: AgentKind = AgentKind::Lua;
2363 type Worker = LuaWorker;
2364}
2365
2366impl SpawnerFactory for LuaInProcessSpawnerFactory {
2367 fn build(
2368 &self,
2369 agent_def: &AgentDef,
2370 _hint: Option<&Value>,
2371 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2372 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
2378 let label = agent_def
2379 .spec
2380 .get("label")
2381 .and_then(|v| v.as_str())
2382 .map(str::to_string)
2383 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
2384 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
2385 let bridges = Arc::new(self.bridges.clone());
2386 let wrapped: WorkerFn = Arc::new(move |inv| {
2387 let source = script.clone();
2388 let bridges = bridges.clone();
2389 Box::pin(run_lua_worker(source, bridges, inv))
2390 });
2391 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
2392 sp.registry.insert(agent_def.name.to_string(), wrapped);
2393 return Ok(Arc::new(sp));
2394 }
2395 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
2396 }
2397}
2398
2399pub struct RustFnInProcessSpawnerFactory {
2413 registry: HashMap<String, WorkerFn>,
2414}
2415
2416impl RustFnInProcessSpawnerFactory {
2417 pub fn new() -> Self {
2419 Self {
2420 registry: HashMap::new(),
2421 }
2422 }
2423
2424 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
2427 where
2428 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
2429 Fut: std::future::Future<
2430 Output = Result<
2431 crate::worker::adapter::WorkerResult,
2432 crate::worker::adapter::WorkerError,
2433 >,
2434 > + Send
2435 + 'static,
2436 {
2437 let f = Arc::new(f);
2438 let wrapped: WorkerFn = Arc::new(move |inv| {
2439 let f = f.clone();
2440 Box::pin(f(inv))
2441 });
2442 self.registry.insert(fn_id.into(), wrapped);
2443 self
2444 }
2445}
2446
2447impl Default for RustFnInProcessSpawnerFactory {
2448 fn default() -> Self {
2449 Self::new()
2450 }
2451}
2452
2453impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
2454 const KIND: AgentKind = AgentKind::RustFn;
2455 type Worker = RustFnWorker;
2456}
2457
2458impl SpawnerFactory for RustFnInProcessSpawnerFactory {
2459 fn build(
2460 &self,
2461 agent_def: &AgentDef,
2462 _hint: Option<&Value>,
2463 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2464 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
2465 }
2466}
2467
2468fn build_inproc_from_registry<W>(
2474 registry: &HashMap<String, WorkerFn>,
2475 agent_def: &AgentDef,
2476 kind_label: &str,
2477) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
2478where
2479 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
2480{
2481 let agent_name = &agent_def.name;
2482 let spec = &agent_def.spec;
2483 let invalid = |msg: String| CompileError::InvalidSpec {
2484 name: agent_name.to_string(),
2485 msg,
2486 };
2487 let fn_id = spec
2488 .get("fn_id")
2489 .and_then(|v| v.as_str())
2490 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
2491 let f = registry
2492 .get(fn_id)
2493 .cloned()
2494 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
2495 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
2496 sp.registry.insert(agent_name.to_string(), f);
2500 Ok(Arc::new(sp))
2501}
2502
2503pub struct LuaWorker {
2508 pub handler: crate::worker::WorkerJoinHandler,
2510}
2511
2512impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
2513 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2514 Self { handler }
2515 }
2516}
2517
2518#[async_trait::async_trait]
2519impl crate::worker::Worker for LuaWorker {
2520 fn id(&self) -> &crate::types::WorkerId {
2521 &self.handler.worker_id
2522 }
2523 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2524 self.handler.cancel.clone()
2525 }
2526 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2527 self.handler.await_completion().await
2528 }
2529}
2530
2531pub struct RustFnWorker {
2536 pub handler: crate::worker::WorkerJoinHandler,
2538}
2539
2540impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
2541 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2542 Self { handler }
2543 }
2544}
2545
2546#[async_trait::async_trait]
2547impl crate::worker::Worker for RustFnWorker {
2548 fn id(&self) -> &crate::types::WorkerId {
2549 &self.handler.worker_id
2550 }
2551 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2552 self.handler.cancel.clone()
2553 }
2554 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2555 self.handler.await_completion().await
2556 }
2557}
2558
2559pub struct OperatorSpawnerFactory {
2627 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
2628 slot_resolver: Arc<std::sync::RwLock<Option<Arc<dyn OperatorSlotResolver>>>>,
2631}
2632
2633impl OperatorSpawnerFactory {
2634 pub fn new() -> Self {
2636 Self {
2637 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
2638 slot_resolver: Arc::new(std::sync::RwLock::new(None)),
2639 }
2640 }
2641
2642 pub fn set_slot_resolver(&self, resolver: Arc<dyn OperatorSlotResolver>) -> &Self {
2651 *self
2652 .slot_resolver
2653 .write()
2654 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned") = Some(resolver);
2655 self
2656 }
2657
2658 pub fn resolve_operator(
2666 &self,
2667 slot: &str,
2668 agent: &str,
2669 ) -> Result<Arc<dyn Operator>, CompileError> {
2670 let invalid = |msg: String| CompileError::InvalidSpec {
2671 name: agent.to_string(),
2672 msg,
2673 };
2674 let resolver = self
2675 .slot_resolver
2676 .read()
2677 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned")
2678 .clone();
2679 if let Some(resolver) = resolver {
2680 return resolver.resolve(slot).ok_or_else(|| {
2681 invalid(format!(
2682 "operator_ref '{slot}': the installed OperatorSlotResolver serves no such \
2683 Operator seat. The seat is declared by Blueprint.operators[]; nothing is \
2684 resolved from the factory's own registry here, because falling back to it \
2685 would dispatch this agent to a backend the seat does not name."
2686 ))
2687 });
2688 }
2689 let operators = self
2690 .operators
2691 .read()
2692 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
2693 operators.get(slot).cloned().ok_or_else(|| {
2694 let mut names: Vec<String> = operators.keys().cloned().collect();
2695 names.sort();
2696 let names_list = if names.is_empty() {
2697 "<none>".to_string()
2698 } else {
2699 names.join(", ")
2700 };
2701 invalid(format!(
2702 "operator_ref '{slot}' not registered in factory. \
2703 Registered sids: [{names_list}]. \
2704 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
2705 ))
2706 })
2707 }
2708
2709 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
2715 self.operators
2716 .write()
2717 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2718 .insert(id.into(), op);
2719 self
2720 }
2721
2722 pub fn unregister_operator(&self, id: &str) -> &Self {
2725 self.operators
2726 .write()
2727 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2728 .remove(id);
2729 self
2730 }
2731}
2732
2733impl Default for OperatorSpawnerFactory {
2734 fn default() -> Self {
2735 Self::new()
2736 }
2737}
2738
2739impl SpawnerFactoryKind for OperatorSpawnerFactory {
2740 const KIND: AgentKind = AgentKind::Operator;
2741 type Worker = crate::operator::OperatorWorker;
2742}
2743
2744impl SpawnerFactory for OperatorSpawnerFactory {
2745 fn build(
2750 &self,
2751 agent_def: &AgentDef,
2752 _hint: Option<&Value>,
2753 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2754 let agent_name = &agent_def.name;
2755 let spec = &agent_def.spec;
2756 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
2762 let invalid = |msg: String| CompileError::InvalidSpec {
2763 name: agent_name.to_string(),
2764 msg,
2765 };
2766 let op_ref = spec
2767 .get("operator_ref")
2768 .and_then(|v| v.as_str())
2769 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
2770 let op = self.resolve_operator(op_ref, agent_name)?;
2774
2775 let worker_binding = agent_def
2782 .profile
2783 .as_ref()
2784 .and_then(|p| p.worker_binding.as_ref())
2785 .map(|variant| WorkerBinding {
2786 variant: variant.clone(),
2787 tools: agent_def
2788 .profile
2789 .as_ref()
2790 .map(|p| p.tools.clone())
2791 .unwrap_or_default(),
2792 request_digest: None,
2796 requested_model: None,
2797 });
2798 if op.requires_worker_binding() && worker_binding.is_none() {
2799 return Err(invalid(format!(
2806 "{WORKER_BINDING_REQUIRED_MSG_PREFIX}. \
2807 Fix by either: \
2808 (a) if authoring the Blueprint JSON directly, add \
2809 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
2810 to the JSON literal; or \
2811 (b) if using an $agent_md file ref, add \
2812 `worker_binding: <subagent-type>` to the agent .md frontmatter."
2813 )));
2814 }
2815 Ok(Arc::new(OperatorSpawner::new(
2816 op,
2817 system_prompt,
2818 worker_binding,
2819 )))
2820 }
2821}
2822
2823#[cfg(test)]
2824mod operator_spawner_factory_worker_binding_tests {
2825 use super::*;
2826 use crate::blueprint::AgentProfile;
2827 use crate::core::ctx::Ctx;
2828 use crate::types::CapToken;
2829 use crate::worker::adapter::{WorkerError, WorkerResult};
2830
2831 struct StubOperator {
2836 requires_binding: bool,
2837 }
2838
2839 #[async_trait]
2840 impl Operator for StubOperator {
2841 async fn execute(
2842 &self,
2843 _ctx: &Ctx,
2844 _system: Option<String>,
2845 _prompt: Value,
2846 _worker: Option<WorkerBinding>,
2847 _worker_token: CapToken,
2848 ) -> Result<WorkerResult, WorkerError> {
2849 Ok(WorkerResult {
2850 value: Value::Null,
2851 ok: true,
2852 stats: None,
2853 })
2854 }
2855
2856 fn requires_worker_binding(&self) -> bool {
2857 self.requires_binding
2858 }
2859 }
2860
2861 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
2862 AgentDef {
2863 name: "test-agent".to_string(),
2864 kind: AgentKind::Operator,
2865 spec: serde_json::json!({ "operator_ref": "op1" }),
2866 profile,
2867 meta: None,
2868 runner: None,
2869 runner_ref: None,
2870 verdict: None,
2871 lints: None,
2872 }
2873 }
2874
2875 #[test]
2876 fn build_fails_loud_when_binding_required_but_absent() {
2877 let factory = OperatorSpawnerFactory::new();
2878 factory.register_operator(
2879 "op1",
2880 Arc::new(StubOperator {
2881 requires_binding: true,
2882 }) as Arc<dyn Operator>,
2883 );
2884 let def = agent_def_with(Some(AgentProfile::default()));
2885 match factory.build(&def, None) {
2886 Err(CompileError::InvalidSpec { name, msg }) => {
2887 assert_eq!(name, "test-agent");
2888 assert!(
2889 msg.contains("worker_binding is required"),
2890 "unexpected message: {msg}"
2891 );
2892 assert!(
2896 msg.contains("agents[N].profile.worker_binding"),
2897 "message missing JSON-direct hint (issue #9): {msg}"
2898 );
2899 assert!(
2900 msg.contains("agent .md frontmatter"),
2901 "message missing $agent_md hint: {msg}"
2902 );
2903 }
2904 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
2905 Ok(_) => panic!("expected compile-time failure, got Ok"),
2906 }
2907 }
2908
2909 #[test]
2916 fn factory_error_message_carries_the_shared_prefix_and_specializes_the_diagnostic() {
2917 let factory = OperatorSpawnerFactory::new();
2918 factory.register_operator(
2919 "op1",
2920 Arc::new(StubOperator {
2921 requires_binding: true,
2922 }) as Arc<dyn Operator>,
2923 );
2924 let def = agent_def_with(Some(AgentProfile::default()));
2925 let err = match factory.build(&def, None) {
2926 Err(err) => err,
2927 Ok(_) => panic!("expected compile-time failure, got Ok"),
2928 };
2929 match &err {
2930 CompileError::InvalidSpec { msg, .. } => {
2931 assert!(
2932 msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX),
2933 "factory message must start with the shared prefix, got: {msg}"
2934 );
2935 }
2936 other => panic!("expected InvalidSpec, got: {other:?}"),
2937 }
2938 let d = mlua_swarm_diag::Diagnostic::from(&err);
2939 assert_eq!(d.kind, "worker-binding-missing");
2940 }
2941
2942 #[test]
2943 fn build_succeeds_when_binding_required_and_present() {
2944 let factory = OperatorSpawnerFactory::new();
2945 factory.register_operator(
2946 "op1",
2947 Arc::new(StubOperator {
2948 requires_binding: true,
2949 }) as Arc<dyn Operator>,
2950 );
2951 let profile = AgentProfile {
2952 worker_binding: Some("code-worker".to_string()),
2953 tools: vec!["Read".to_string(), "Edit".to_string()],
2954 ..Default::default()
2955 };
2956 let def = agent_def_with(Some(profile));
2957 assert!(
2958 factory.build(&def, None).is_ok(),
2959 "expected Ok when worker_binding is declared"
2960 );
2961 }
2962
2963 #[test]
2964 fn build_succeeds_when_binding_not_required_and_absent() {
2965 let factory = OperatorSpawnerFactory::new();
2966 factory.register_operator(
2967 "op1",
2968 Arc::new(StubOperator {
2969 requires_binding: false,
2970 }) as Arc<dyn Operator>,
2971 );
2972 let def = agent_def_with(Some(AgentProfile::default()));
2973 assert!(
2974 factory.build(&def, None).is_ok(),
2975 "backends that don't require a binding must not be gated by its absence"
2976 );
2977 }
2978}
2979
2980#[cfg(test)]
2988mod lua_inline_source_tests {
2989 use super::*;
2990 use crate::types::{CapToken, Role, StepId};
2991
2992 fn agent(name: &str, spec: Value) -> AgentDef {
2993 AgentDef {
2994 name: name.to_string(),
2995 kind: AgentKind::Lua,
2996 spec,
2997 profile: None,
2998 meta: None,
2999 runner: None,
3000 runner_ref: None,
3001 verdict: None,
3002 lints: None,
3003 }
3004 }
3005
3006 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
3007 crate::worker::adapter::WorkerInvocation::new(
3008 CapToken {
3009 agent_id: "a".into(),
3010 role: Role::Worker,
3011 scopes: vec!["*".into()],
3012 issued_at: 0,
3013 expire_at: u64::MAX / 2,
3014 max_uses: None,
3015 nonce: "test-nonce".into(),
3016 sig_hex: "".into(),
3017 },
3018 StepId::parse("ST-test").expect("StepId parse"),
3019 1,
3020 "g",
3021 prompt,
3022 )
3023 }
3024
3025 #[test]
3026 fn build_accepts_inline_source_without_pre_registration() {
3027 let factory = LuaInProcessSpawnerFactory::new();
3028 let def = agent(
3029 "g",
3030 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
3031 );
3032 assert!(
3033 factory.build(&def, None).is_ok(),
3034 "inline spec.source must build without a pre-registered fn_id"
3035 );
3036 }
3037
3038 #[test]
3039 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
3040 let factory = LuaInProcessSpawnerFactory::new();
3041 let def = agent("g", serde_json::json!({}));
3042 match factory.build(&def, None) {
3043 Err(CompileError::InvalidSpec { msg, .. }) => {
3044 assert!(
3045 msg.contains("fn_id"),
3046 "empty spec must still surface the fn_id-required message: {msg}"
3047 );
3048 }
3049 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3050 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
3053 }
3054 }
3055
3056 #[tokio::test]
3060 async fn inline_source_evaluates_and_marshals_result() {
3061 let source =
3062 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
3063 let out = run_lua_worker(
3064 std::sync::Arc::new(source),
3065 std::sync::Arc::new(HashMap::new()),
3066 test_invocation("hello"),
3067 )
3068 .await
3069 .expect("lua worker ok");
3070 assert_eq!(out.value, serde_json::json!("hello!"));
3071 assert!(out.ok);
3072 }
3073
3074 #[tokio::test]
3075 async fn inline_source_can_signal_agent_level_failure() {
3076 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
3079 let out = run_lua_worker(
3080 std::sync::Arc::new(source),
3081 std::sync::Arc::new(HashMap::new()),
3082 test_invocation("input"),
3083 )
3084 .await
3085 .expect("lua worker ok");
3086 assert_eq!(out.value, serde_json::json!("nope"));
3087 assert!(!out.ok);
3088 }
3089}
3090
3091#[cfg(test)]
3094mod meta_ref_validation_tests {
3095 use super::*;
3096 use crate::blueprint::{AgentMeta, MetaDef};
3097 use crate::worker::adapter::WorkerResult;
3098
3099 fn registry_with_echo() -> SpawnerRegistry {
3100 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3101 Ok(WorkerResult {
3102 value: Value::String(inv.prompt),
3103 ok: true,
3104 stats: None,
3105 })
3106 });
3107 let mut reg = SpawnerRegistry::new();
3108 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3109 reg
3110 }
3111
3112 fn rustfn_agent(name: &str) -> AgentDef {
3113 AgentDef {
3114 name: name.to_string(),
3115 kind: AgentKind::RustFn,
3116 spec: serde_json::json!({ "fn_id": "echo" }),
3117 profile: None,
3118 meta: None,
3119 runner: None,
3120 runner_ref: None,
3121 verdict: None,
3122 lints: None,
3123 }
3124 }
3125
3126 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
3127 FlowNode::Step {
3128 ref_: agent_ref.to_string(),
3129 in_,
3130 out: Expr::Path {
3131 at: "$.output".parse().expect("literal test path: $.output"),
3132 },
3133 }
3134 }
3135
3136 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
3137 Blueprint {
3138 schema_version: crate::blueprint::current_schema_version(),
3139 id: "meta-ref-ut".into(),
3140 flow,
3141 agents,
3142 operators: vec![],
3143 metas,
3144 hints: Default::default(),
3145 strategy: Default::default(),
3146 metadata: BlueprintMetadata::default(),
3147 spawner_hints: Default::default(),
3148 default_agent_kind: AgentKind::Operator,
3149 default_operator_kind: None,
3150 default_init_ctx: None,
3151 default_agent_ctx: None,
3152 default_context_policy: None,
3153 projection_placement: None,
3154 audits: vec![],
3155 degradation_policy: None,
3156 runners: vec![],
3157 default_runner: None,
3158 subprocesses: vec![],
3159 check_policy: None,
3160 blueprint_ref_includes: Vec::new(),
3161 }
3162 }
3163
3164 #[test]
3165 fn valid_meta_ref_compiles() {
3166 let mut agent = rustfn_agent("worker");
3167 agent.meta = Some(AgentMeta {
3168 meta_ref: Some("shared".to_string()),
3169 ..Default::default()
3170 });
3171 let bp = minimal_bp(
3172 vec![agent],
3173 vec![MetaDef {
3174 name: "shared".into(),
3175 ctx: serde_json::json!({ "k": "v" }),
3176 }],
3177 simple_flow(
3178 "worker",
3179 Expr::Path {
3180 at: "$.input".parse().expect("literal test path: $.input"),
3181 },
3182 ),
3183 );
3184 let compiler = Compiler::new(registry_with_echo());
3185 assert!(
3186 compiler.compile(&bp).is_ok(),
3187 "a resolvable AgentMeta.meta_ref must compile"
3188 );
3189 }
3190
3191 #[test]
3192 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
3193 let mut agent = rustfn_agent("worker");
3194 agent.meta = Some(AgentMeta {
3195 meta_ref: Some("missing".to_string()),
3196 ..Default::default()
3197 });
3198 let bp = minimal_bp(
3199 vec![agent],
3200 vec![],
3201 simple_flow(
3202 "worker",
3203 Expr::Path {
3204 at: "$.input".parse().expect("literal test path: $.input"),
3205 },
3206 ),
3207 );
3208 let compiler = Compiler::new(registry_with_echo());
3209 match compiler.compile(&bp) {
3210 Err(CompileError::UnresolvedMetaRef {
3211 where_,
3212 meta_ref,
3213 defined,
3214 }) => {
3215 assert!(
3216 where_.contains("worker"),
3217 "where_ must name the agent: {where_}"
3218 );
3219 assert_eq!(meta_ref, "missing");
3220 assert!(defined.is_empty());
3221 }
3222 Err(other) => {
3223 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3224 }
3225 Ok(_) => panic!("expected compile-time failure, got Ok"),
3226 }
3227 }
3228
3229 #[test]
3230 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
3231 let agent = rustfn_agent("worker");
3232 let in_ = Expr::Lit {
3233 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
3234 };
3235 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
3236 let compiler = Compiler::new(registry_with_echo());
3237 match compiler.compile(&bp) {
3238 Err(CompileError::UnresolvedMetaRef {
3239 where_, meta_ref, ..
3240 }) => {
3241 assert!(
3242 where_.contains("worker"),
3243 "where_ must name the offending step: {where_}"
3244 );
3245 assert_eq!(meta_ref, "missing");
3246 }
3247 Err(other) => {
3248 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3249 }
3250 Ok(_) => panic!("expected compile-time failure, got Ok"),
3251 }
3252 }
3253
3254 #[test]
3255 fn path_op_input_with_no_static_envelope_compiles_fine() {
3256 let agent = rustfn_agent("worker");
3257 let bp = minimal_bp(
3258 vec![agent],
3259 vec![],
3260 simple_flow(
3261 "worker",
3262 Expr::Path {
3263 at: "$.input".parse().expect("literal test path: $.input"),
3264 },
3265 ),
3266 );
3267 let compiler = Compiler::new(registry_with_echo());
3268 assert!(
3269 compiler.compile(&bp).is_ok(),
3270 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
3271 );
3272 }
3273}
3274
3275#[cfg(test)]
3277mod audit_agent_validation_tests {
3278 use super::*;
3279 use crate::worker::adapter::WorkerResult;
3280 use mlua_swarm_schema::{AuditDef, AuditMode};
3281
3282 fn registry_with_echo() -> SpawnerRegistry {
3283 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3284 Ok(WorkerResult {
3285 value: Value::String(inv.prompt),
3286 ok: true,
3287 stats: None,
3288 })
3289 });
3290 let mut reg = SpawnerRegistry::new();
3291 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3292 reg
3293 }
3294
3295 fn rustfn_agent(name: &str) -> AgentDef {
3296 AgentDef {
3297 name: name.to_string(),
3298 kind: AgentKind::RustFn,
3299 spec: serde_json::json!({ "fn_id": "echo" }),
3300 profile: None,
3301 meta: None,
3302 runner: None,
3303 runner_ref: None,
3304 verdict: None,
3305 lints: None,
3306 }
3307 }
3308
3309 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
3310 Blueprint {
3311 schema_version: crate::blueprint::current_schema_version(),
3312 id: "audit-ref-ut".into(),
3313 flow: FlowNode::Step {
3314 ref_: "worker".to_string(),
3315 in_: Expr::Path {
3316 at: "$.input".parse().expect("literal test path: $.input"),
3317 },
3318 out: Expr::Path {
3319 at: "$.output".parse().expect("literal test path: $.output"),
3320 },
3321 },
3322 agents,
3323 operators: vec![],
3324 metas: vec![],
3325 hints: Default::default(),
3326 strategy: Default::default(),
3327 metadata: BlueprintMetadata::default(),
3328 spawner_hints: Default::default(),
3329 default_agent_kind: AgentKind::Operator,
3330 default_operator_kind: None,
3331 default_init_ctx: None,
3332 default_agent_ctx: None,
3333 default_context_policy: None,
3334 projection_placement: None,
3335 audits,
3336 degradation_policy: None,
3337 runners: vec![],
3338 default_runner: None,
3339 subprocesses: vec![],
3340 check_policy: None,
3341 blueprint_ref_includes: Vec::new(),
3342 }
3343 }
3344
3345 #[test]
3346 fn unresolved_audit_agent_is_a_loud_compile_error() {
3347 let bp = minimal_bp(
3348 vec![rustfn_agent("worker")],
3349 vec![AuditDef {
3350 agent: "missing-auditor".to_string(),
3351 steps: None,
3352 mode: AuditMode::default(),
3353 }],
3354 );
3355 let compiler = Compiler::new(registry_with_echo());
3356 match compiler.compile(&bp) {
3357 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
3358 assert_eq!(agent, "missing-auditor");
3359 assert_eq!(defined, vec!["worker".to_string()]);
3360 }
3361 Err(other) => {
3362 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
3363 }
3364 Ok(_) => panic!("expected compile-time failure, got Ok"),
3365 }
3366 }
3367
3368 #[test]
3369 fn resolved_audit_agent_compiles_fine() {
3370 let bp = minimal_bp(
3371 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
3372 vec![AuditDef {
3373 agent: "auditor".to_string(),
3374 steps: None,
3375 mode: AuditMode::default(),
3376 }],
3377 );
3378 let compiler = Compiler::new(registry_with_echo());
3379 assert!(
3380 compiler.compile(&bp).is_ok(),
3381 "an audits[].agent that names a declared AgentDef must compile"
3382 );
3383 }
3384}
3385
3386#[cfg(test)]
3395mod operator_ref_resolution_tests {
3396 use super::*;
3397 use crate::core::ctx::Ctx;
3398 use crate::types::CapToken;
3399 use crate::worker::adapter::{WorkerError, WorkerResult};
3400 use std::sync::Mutex;
3401
3402 type Seen = Arc<Mutex<Vec<(String, Option<Value>)>>>;
3404
3405 struct RecordingOperatorFactory {
3409 seen: Seen,
3410 }
3411
3412 impl SpawnerFactory for RecordingOperatorFactory {
3413 fn build(
3414 &self,
3415 agent_def: &AgentDef,
3416 hint: Option<&Value>,
3417 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3418 self.seen
3419 .lock()
3420 .expect("RecordingOperatorFactory.seen poisoned")
3421 .push((agent_def.name.clone(), hint.cloned()));
3422 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3423 let worker: WorkerFn = Arc::new(|_inv| {
3424 Box::pin(async move {
3425 Ok(WorkerResult {
3426 value: Value::Null,
3427 ok: true,
3428 stats: None,
3429 })
3430 })
3431 });
3432 spawner.registry.insert(agent_def.name.clone(), worker);
3433 Ok(Arc::new(spawner))
3434 }
3435 }
3436
3437 impl SpawnerFactoryKind for RecordingOperatorFactory {
3438 const KIND: AgentKind = AgentKind::Operator;
3439 type Worker = crate::operator::OperatorWorker;
3440 }
3441
3442 struct RecordingLuaFactory {
3445 seen: Seen,
3446 }
3447
3448 impl SpawnerFactory for RecordingLuaFactory {
3449 fn build(
3450 &self,
3451 agent_def: &AgentDef,
3452 hint: Option<&Value>,
3453 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3454 self.seen
3455 .lock()
3456 .expect("RecordingLuaFactory.seen poisoned")
3457 .push((agent_def.name.clone(), hint.cloned()));
3458 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3459 let worker: WorkerFn = Arc::new(|_inv| {
3460 Box::pin(async move {
3461 Ok(WorkerResult {
3462 value: Value::Null,
3463 ok: true,
3464 stats: None,
3465 })
3466 })
3467 });
3468 spawner.registry.insert(agent_def.name.clone(), worker);
3469 Ok(Arc::new(spawner))
3470 }
3471 }
3472
3473 impl SpawnerFactoryKind for RecordingLuaFactory {
3474 const KIND: AgentKind = AgentKind::Lua;
3475 type Worker = LuaWorker;
3476 }
3477
3478 fn recording_compiler() -> (Compiler, Seen, Seen) {
3479 let operator_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3480 let lua_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3481 let mut registry = SpawnerRegistry::new();
3482 registry.register::<RecordingOperatorFactory>(Arc::new(RecordingOperatorFactory {
3483 seen: operator_seen.clone(),
3484 }));
3485 registry.register::<RecordingLuaFactory>(Arc::new(RecordingLuaFactory {
3486 seen: lua_seen.clone(),
3487 }));
3488 (Compiler::new(registry), operator_seen, lua_seen)
3489 }
3490
3491 fn bp_with_operator_and_lua_agents() -> Blueprint {
3495 serde_json::from_value(serde_json::json!({
3496 "schema_version": crate::blueprint::current_schema_version(),
3497 "id": "operator-pin-ut",
3498 "flow": {
3499 "kind": "step",
3500 "ref": "planner",
3501 "in": { "op": "path", "at": "$.input" },
3502 "out": { "op": "path", "at": "$.output" }
3503 },
3504 "agents": [
3505 {
3506 "name": "planner",
3507 "kind": "operator",
3508 "spec": { "operator_ref": "main-ai" }
3509 },
3510 {
3511 "name": "scorer",
3512 "kind": "lua",
3513 "spec": { "source": "return { value = 1, ok = true }" }
3514 }
3515 ],
3516 "operators": [{ "name": "main-ai" }],
3517 "hints": { "per_agent": { "planner": { "authored": "keep-me" } } },
3518 "strategy": { "strict_refs": false }
3519 }))
3520 .expect("test Blueprint literal")
3521 }
3522
3523 fn hint_for(seen: &Seen, agent: &str) -> Option<Value> {
3524 seen.lock()
3525 .expect("seen poisoned")
3526 .iter()
3527 .find(|(name, _)| name == agent)
3528 .map(|(_, hint)| hint.clone())
3529 .expect("agent was never built")
3530 }
3531
3532 #[test]
3537 fn the_compile_hands_the_factory_the_authored_hint_untouched() {
3538 let (compiler, operator_seen, lua_seen) = recording_compiler();
3539 let bp = bp_with_operator_and_lua_agents();
3540 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3541 compiler.compile_bound(&bp, &bound).expect("compile");
3542
3543 assert_eq!(
3544 hint_for(&operator_seen, "planner"),
3545 Some(serde_json::json!({ "authored": "keep-me" })),
3546 "the compile must hand over the authored hint verbatim"
3547 );
3548 assert_eq!(
3549 hint_for(&lua_seen, "scorer"),
3550 None,
3551 "an agent with no authored hint must still be built with None"
3552 );
3553 }
3554
3555 #[test]
3558 fn a_non_object_authored_hint_is_none_of_the_compilers_business() {
3559 let (compiler, _operator_seen, _lua_seen) = recording_compiler();
3560 let mut bp = bp_with_operator_and_lua_agents();
3561 bp.hints
3562 .per_agent
3563 .insert("planner".to_string(), Value::String("not-an-object".into()));
3564 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3565 assert!(
3566 compiler.compile_bound(&bp, &bound).is_ok(),
3567 "the compile must accept whatever hint shape the author declared"
3568 );
3569 }
3570
3571 struct StubOperator {
3577 requires_binding: bool,
3578 }
3579
3580 #[async_trait]
3581 impl Operator for StubOperator {
3582 async fn execute(
3583 &self,
3584 _ctx: &Ctx,
3585 _system: Option<String>,
3586 _prompt: Value,
3587 _worker: Option<WorkerBinding>,
3588 _worker_token: CapToken,
3589 ) -> Result<WorkerResult, WorkerError> {
3590 Ok(WorkerResult {
3591 value: Value::Null,
3592 ok: true,
3593 stats: None,
3594 })
3595 }
3596
3597 fn requires_worker_binding(&self) -> bool {
3598 self.requires_binding
3599 }
3600 }
3601
3602 fn operator_agent() -> AgentDef {
3603 AgentDef {
3604 name: "planner".to_string(),
3605 kind: AgentKind::Operator,
3606 spec: serde_json::json!({ "operator_ref": "main-ai" }),
3607 profile: None,
3608 meta: None,
3609 runner: None,
3610 runner_ref: None,
3611 verdict: None,
3612 lints: None,
3613 }
3614 }
3615
3616 struct StubResolver {
3620 seats: Vec<&'static str>,
3621 asked: Mutex<Vec<String>>,
3622 }
3623
3624 impl OperatorSlotResolver for StubResolver {
3625 fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>> {
3626 self.asked
3627 .lock()
3628 .expect("StubResolver.asked poisoned")
3629 .push(slot.to_string());
3630 self.seats.contains(&slot).then(|| {
3631 Arc::new(StubOperator {
3632 requires_binding: false,
3633 }) as Arc<dyn Operator>
3634 })
3635 }
3636 }
3637
3638 #[test]
3643 fn an_installed_resolver_answers_the_seat_and_the_registry_is_not_consulted() {
3644 let factory = OperatorSpawnerFactory::new();
3645 factory.register_operator(
3646 "main-ai",
3647 Arc::new(StubOperator {
3648 requires_binding: true,
3649 }) as Arc<dyn Operator>,
3650 );
3651 let resolver = Arc::new(StubResolver {
3652 seats: vec!["main-ai"],
3653 asked: Mutex::new(Vec::new()),
3654 });
3655 factory.set_slot_resolver(resolver.clone());
3656
3657 assert!(
3658 factory.build(&operator_agent(), None).is_ok(),
3659 "the installed resolver must answer the seat, not the registry entry \
3660 registered under the same name"
3661 );
3662 assert_eq!(
3663 *resolver.asked.lock().expect("asked"),
3664 vec!["main-ai".to_string()],
3665 "the resolver is asked for the seat the AgentDef declares"
3666 );
3667 }
3668
3669 #[test]
3673 fn a_resolver_miss_fails_loud_and_never_falls_back_to_the_registry() {
3674 let factory = OperatorSpawnerFactory::new();
3675 factory.register_operator(
3676 "main-ai",
3677 Arc::new(StubOperator {
3678 requires_binding: false,
3679 }) as Arc<dyn Operator>,
3680 );
3681 factory.set_slot_resolver(Arc::new(StubResolver {
3682 seats: vec!["some-other-seat"],
3683 asked: Mutex::new(Vec::new()),
3684 }));
3685
3686 match factory.build(&operator_agent(), None) {
3687 Err(CompileError::InvalidSpec { name, msg }) => {
3688 assert_eq!(name, "planner");
3689 assert!(
3690 msg.contains("main-ai"),
3691 "message must name the seat that went unserved: {msg}"
3692 );
3693 assert!(
3694 msg.contains("OperatorSlotResolver"),
3695 "message must say which side refused, so the wiring is the \
3696 obvious suspect: {msg}"
3697 );
3698 }
3699 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3700 Ok(_) => panic!(
3701 "an unserved seat must fail the compile, not silently resolve the \
3702 registry entry"
3703 ),
3704 }
3705 }
3706
3707 #[test]
3710 fn without_a_resolver_the_registry_answers_with_the_historical_message() {
3711 let factory = OperatorSpawnerFactory::new();
3712 match factory.build(&operator_agent(), None) {
3713 Err(CompileError::InvalidSpec { msg, .. }) => {
3714 assert!(
3715 msg.contains("operator_ref 'main-ai' not registered in factory"),
3716 "the registry-side message must stay the historical one: {msg}"
3717 );
3718 }
3719 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3720 Ok(_) => panic!("an unregistered seat must still fail"),
3721 }
3722 factory.register_operator(
3723 "main-ai",
3724 Arc::new(StubOperator {
3725 requires_binding: false,
3726 }) as Arc<dyn Operator>,
3727 );
3728 assert!(
3729 factory.build(&operator_agent(), None).is_ok(),
3730 "a registered backend must still resolve the seat directly"
3731 );
3732 }
3733}
3734
3735#[cfg(test)]
3738mod projection_placement_compile_tests {
3739 use super::*;
3740 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
3741 use crate::worker::adapter::WorkerResult;
3742 use mlua_swarm_schema::ProjectionPlacementSpec;
3743
3744 fn registry_with_echo() -> SpawnerRegistry {
3745 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3746 Ok(WorkerResult {
3747 value: Value::String(inv.prompt),
3748 ok: true,
3749 stats: None,
3750 })
3751 });
3752 let mut reg = SpawnerRegistry::new();
3753 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3754 reg
3755 }
3756
3757 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
3758 Blueprint {
3759 schema_version: crate::blueprint::current_schema_version(),
3760 id: "projection-placement-ut".into(),
3761 flow: FlowNode::Step {
3762 ref_: "worker".to_string(),
3763 in_: Expr::Path {
3764 at: "$.input".parse().expect("literal test path: $.input"),
3765 },
3766 out: Expr::Path {
3767 at: "$.output".parse().expect("literal test path: $.output"),
3768 },
3769 },
3770 agents: vec![AgentDef {
3771 name: "worker".to_string(),
3772 kind: AgentKind::RustFn,
3773 spec: serde_json::json!({ "fn_id": "echo" }),
3774 profile: None,
3775 meta: None,
3776 runner: None,
3777 runner_ref: None,
3778 verdict: None,
3779 lints: None,
3780 }],
3781 operators: vec![],
3782 metas: vec![],
3783 hints: Default::default(),
3784 strategy: Default::default(),
3785 metadata: BlueprintMetadata::default(),
3786 spawner_hints: Default::default(),
3787 default_agent_kind: AgentKind::Operator,
3788 default_operator_kind: None,
3789 default_init_ctx: None,
3790 default_agent_ctx: None,
3791 default_context_policy: None,
3792 projection_placement,
3793 audits: vec![],
3794 degradation_policy: None,
3795 runners: vec![],
3796 default_runner: None,
3797 subprocesses: vec![],
3798 check_policy: None,
3799 blueprint_ref_includes: Vec::new(),
3800 }
3801 }
3802
3803 #[test]
3804 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
3805 let bp = minimal_bp(None);
3806 let compiled = Compiler::new(registry_with_echo())
3807 .compile(&bp)
3808 .expect("undeclared projection_placement compiles");
3809 assert_eq!(
3810 *compiled.projection_placement,
3811 ProjectionPlacement::default()
3812 );
3813 }
3814
3815 #[test]
3816 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
3817 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3818 root: Some("project_root".to_string()),
3819 dir_template: Some("custom/{task_id}/out".to_string()),
3820 }));
3821 let compiled = Compiler::new(registry_with_echo())
3822 .compile(&bp)
3823 .expect("valid projection_placement compiles");
3824 assert_eq!(
3825 compiled.projection_placement.root_preference,
3826 RootPreference::ProjectRoot
3827 );
3828 assert_eq!(
3829 compiled.projection_placement.dir_template,
3830 "custom/{task_id}/out"
3831 );
3832 }
3833
3834 #[test]
3835 fn declared_invalid_dir_template_rejects_compile() {
3836 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3837 root: None,
3838 dir_template: Some("workspace/tasks/ctx".to_string()), }));
3840 match Compiler::new(registry_with_echo()).compile(&bp) {
3841 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3842 Err(other) => {
3843 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3844 }
3845 Ok(_) => {
3846 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
3847 }
3848 }
3849 }
3850
3851 #[test]
3852 fn declared_invalid_root_literal_rejects_compile() {
3853 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3854 root: Some("nope".to_string()),
3855 dir_template: None,
3856 }));
3857 match Compiler::new(registry_with_echo()).compile(&bp) {
3858 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3859 Err(other) => {
3860 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3861 }
3862 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
3863 }
3864 }
3865}
3866
3867#[cfg(test)]
3869mod verdict_contract_lint_tests {
3870 use super::*;
3871 use crate::worker::adapter::WorkerResult;
3872
3873 fn registry_with_echo() -> SpawnerRegistry {
3874 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3875 Ok(WorkerResult {
3876 value: Value::String(inv.prompt),
3877 ok: true,
3878 stats: None,
3879 })
3880 });
3881 let mut reg = SpawnerRegistry::new();
3882 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3883 reg
3884 }
3885
3886 fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
3887 AgentDef {
3888 name: "gate".to_string(),
3889 kind: AgentKind::RustFn,
3890 spec: serde_json::json!({ "fn_id": "echo" }),
3891 profile: None,
3892 meta: None,
3893 runner: None,
3894 runner_ref: None,
3895 verdict,
3896 lints: None,
3897 }
3898 }
3899
3900 fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
3901 Blueprint {
3902 schema_version: crate::blueprint::current_schema_version(),
3903 id: "verdict-contract-ut".into(),
3904 flow,
3905 agents: vec![agent],
3906 operators: vec![],
3907 metas: vec![],
3908 hints: Default::default(),
3909 strategy: Default::default(),
3910 metadata: BlueprintMetadata::default(),
3911 spawner_hints: Default::default(),
3912 default_agent_kind: AgentKind::Operator,
3913 default_operator_kind: None,
3914 default_init_ctx: None,
3915 default_agent_ctx: None,
3916 default_context_policy: None,
3917 projection_placement: None,
3918 audits: vec![],
3919 degradation_policy: None,
3920 runners: vec![],
3921 default_runner: None,
3922 subprocesses: vec![],
3923 check_policy: None,
3924 blueprint_ref_includes: Vec::new(),
3925 }
3926 }
3927
3928 fn step(ref_: &str, out_path: &str) -> FlowNode {
3929 FlowNode::Step {
3930 ref_: ref_.to_string(),
3931 in_: Expr::Lit { value: Value::Null },
3932 out: Expr::Path {
3933 at: out_path.parse().expect("literal test path"),
3934 },
3935 }
3936 }
3937
3938 fn noop() -> FlowNode {
3939 FlowNode::Seq { children: vec![] }
3940 }
3941
3942 fn eq_cond(path: &str, lit: &str) -> Expr {
3943 Expr::Eq {
3944 lhs: Box::new(Expr::Path {
3945 at: path.parse().expect("literal test path"),
3946 }),
3947 rhs: Box::new(Expr::Lit {
3948 value: Value::String(lit.to_string()),
3949 }),
3950 }
3951 }
3952
3953 fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
3954 FlowNode::Branch {
3955 cond,
3956 then_: Box::new(then_),
3957 else_: Box::new(else_),
3958 }
3959 }
3960
3961 fn body_contract(values: &[&str]) -> VerdictContract {
3962 VerdictContract {
3963 channel: VerdictChannel::Body,
3964 values: values.iter().map(|v| v.to_string()).collect(),
3965 }
3966 }
3967
3968 fn part_contract(values: &[&str]) -> VerdictContract {
3969 VerdictContract {
3970 channel: VerdictChannel::Part,
3971 values: values.iter().map(|v| v.to_string()).collect(),
3972 }
3973 }
3974
3975 #[test]
3976 fn contract_with_correct_body_channel_and_value_compiles() {
3977 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
3978 let flow = FlowNode::Seq {
3979 children: vec![
3980 step("gate", "$.verdict"),
3981 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
3982 ],
3983 };
3984 let bp = minimal_bp(agent, flow);
3985 assert!(
3986 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
3987 "a cond addressing the bare step output must match a channel: \"body\" contract"
3988 );
3989 }
3990
3991 #[test]
3992 fn contract_with_correct_part_channel_and_value_compiles() {
3993 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
3994 let flow = FlowNode::Seq {
3995 children: vec![
3996 step("gate", "$.gate"),
3997 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
3998 ],
3999 };
4000 let bp = minimal_bp(agent, flow);
4001 assert!(
4002 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4003 "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
4004 );
4005 }
4006
4007 #[test]
4008 fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
4009 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4013 let flow = FlowNode::Seq {
4014 children: vec![
4015 step("gate", "$.gate"),
4016 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4017 ],
4018 };
4019 let bp = minimal_bp(agent, flow);
4020 match Compiler::new(registry_with_echo()).compile(&bp) {
4021 Err(CompileError::VerdictChannelMismatch {
4022 where_,
4023 agent,
4024 expected_channel,
4025 actual_shape,
4026 }) => {
4027 assert_eq!(agent, "gate");
4028 assert_eq!(expected_channel, "body");
4029 assert_eq!(actual_shape, "part");
4030 assert!(where_.contains("Branch cond"), "where_: {where_}");
4031 }
4032 Err(other) => {
4033 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4034 }
4035 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4036 }
4037 }
4038
4039 #[test]
4040 fn part_channel_contract_rejects_cond_addressing_bare_output() {
4041 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4044 let flow = FlowNode::Seq {
4045 children: vec![
4046 step("gate", "$.verdict"),
4047 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4048 ],
4049 };
4050 let bp = minimal_bp(agent, flow);
4051 match Compiler::new(registry_with_echo()).compile(&bp) {
4052 Err(CompileError::VerdictChannelMismatch {
4053 agent,
4054 expected_channel,
4055 actual_shape,
4056 ..
4057 }) => {
4058 assert_eq!(agent, "gate");
4059 assert_eq!(expected_channel, "part");
4060 assert_eq!(actual_shape, "body");
4061 }
4062 Err(other) => {
4063 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4064 }
4065 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4066 }
4067 }
4068
4069 #[test]
4070 fn contract_rejects_lit_outside_declared_values() {
4071 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4072 let flow = FlowNode::Seq {
4073 children: vec![
4074 step("gate", "$.verdict"),
4075 branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
4076 ],
4077 };
4078 let bp = minimal_bp(agent, flow);
4079 match Compiler::new(registry_with_echo()).compile(&bp) {
4080 Err(CompileError::VerdictValueNotInContract {
4081 agent,
4082 value,
4083 values,
4084 ..
4085 }) => {
4086 assert_eq!(agent, "gate");
4087 assert_eq!(value, "UNKNOWN");
4088 assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4089 }
4090 Err(other) => {
4091 panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
4092 }
4093 Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
4094 }
4095 }
4096
4097 #[test]
4098 fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
4099 let agent = gate_agent(None);
4100 let flow = FlowNode::Seq {
4101 children: vec![
4102 step("gate", "$.verdict"),
4103 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4104 ],
4105 };
4106 let bp = minimal_bp(agent, flow);
4107 assert!(
4108 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4109 "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
4110 );
4111 }
4112
4113 #[test]
4114 fn in_expr_with_lit_haystack_members_compiles() {
4115 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4116 let cond = Expr::In {
4117 needle: Box::new(Expr::Path {
4118 at: "$.verdict".parse().expect("literal test path"),
4119 }),
4120 haystack: Box::new(Expr::Lit {
4121 value: serde_json::json!(["PASS", "BLOCKED"]),
4122 }),
4123 };
4124 let flow = FlowNode::Seq {
4125 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4126 };
4127 let bp = minimal_bp(agent, flow);
4128 assert!(
4129 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4130 "an `In` haystack whose every Lit is a declared value must compile"
4131 );
4132 }
4133
4134 #[test]
4141 fn strict_mode_rejects_unhandled_declared_value() {
4142 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4143 let flow = FlowNode::Seq {
4144 children: vec![
4145 step("gate", "$.verdict"),
4146 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4147 ],
4148 };
4149 let mut bp = minimal_bp(agent, flow);
4150 bp.metadata.strict_verdict_handling = Some(true);
4151 match Compiler::new(registry_with_echo()).compile(&bp) {
4152 Err(CompileError::VerdictValueUnhandled {
4153 agent,
4154 value,
4155 declared_values,
4156 step_ref,
4157 }) => {
4158 assert_eq!(agent, "gate");
4159 assert_eq!(value, "PASS");
4160 assert_eq!(
4161 declared_values,
4162 vec!["PASS".to_string(), "BLOCKED".to_string()]
4163 );
4164 assert_eq!(step_ref, "gate");
4165 }
4166 Err(other) => {
4167 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4168 }
4169 Ok(_) => panic!(
4170 "expected compile-time rejection for a declared verdict value with no \
4171 downstream handler under strict_verdict_handling=Some(true)"
4172 ),
4173 }
4174 }
4175
4176 #[test]
4183 fn default_mode_permits_unhandled_declared_value() {
4184 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4185 let flow = FlowNode::Seq {
4186 children: vec![
4187 step("gate", "$.verdict"),
4188 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4189 ],
4190 };
4191 let bp = minimal_bp(agent, flow);
4192 assert!(
4194 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4195 "default mode must never reject a Blueprint for unhandled declared values \
4196 (opt-in, back-compat with GH #50)"
4197 );
4198 }
4199
4200 #[test]
4205 fn strict_mode_accepts_all_declared_values_handled() {
4206 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4207 let flow = FlowNode::Seq {
4210 children: vec![
4211 step("gate", "$.verdict"),
4212 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4213 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4214 ],
4215 };
4216 let mut bp = minimal_bp(agent, flow);
4217 bp.metadata.strict_verdict_handling = Some(true);
4218 assert!(
4219 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4220 "strict mode must accept a Blueprint that handles every declared value"
4221 );
4222 }
4223
4224 #[test]
4228 fn strict_mode_accepts_declared_values_covered_by_in_expr() {
4229 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4230 let cond = Expr::In {
4231 needle: Box::new(Expr::Path {
4232 at: "$.verdict".parse().expect("literal test path"),
4233 }),
4234 haystack: Box::new(Expr::Lit {
4235 value: serde_json::json!(["PASS", "BLOCKED"]),
4236 }),
4237 };
4238 let flow = FlowNode::Seq {
4239 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4240 };
4241 let mut bp = minimal_bp(agent, flow);
4242 bp.metadata.strict_verdict_handling = Some(true);
4243 assert!(
4244 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4245 "strict mode must accept an `In` haystack that covers every declared value"
4246 );
4247 }
4248
4249 #[test]
4253 fn strict_mode_rejects_unhandled_part_channel_value() {
4254 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4255 let flow = FlowNode::Seq {
4256 children: vec![
4257 step("gate", "$.gate"),
4258 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4259 ],
4260 };
4261 let mut bp = minimal_bp(agent, flow);
4262 bp.metadata.strict_verdict_handling = Some(true);
4263 match Compiler::new(registry_with_echo()).compile(&bp) {
4264 Err(CompileError::VerdictValueUnhandled {
4265 agent,
4266 value,
4267 step_ref,
4268 ..
4269 }) => {
4270 assert_eq!(agent, "gate");
4271 assert_eq!(value, "PASS");
4272 assert_eq!(step_ref, "gate");
4273 }
4274 Err(other) => {
4275 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4276 }
4277 Ok(_) => panic!(
4278 "expected compile-time rejection for a declared verdict value with no \
4279 downstream handler (part channel) under strict_verdict_handling=Some(true)"
4280 ),
4281 }
4282 }
4283
4284 fn lints(
4290 pairs: &[(&str, mlua_swarm_schema::LintSetting)],
4291 ) -> Option<std::collections::BTreeMap<String, mlua_swarm_schema::LintSetting>> {
4292 Some(
4293 pairs
4294 .iter()
4295 .map(|(key, setting)| ((*key).to_string(), *setting))
4296 .collect(),
4297 )
4298 }
4299
4300 fn bp_with_unhandled_value() -> Blueprint {
4304 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4305 let flow = FlowNode::Seq {
4306 children: vec![
4307 step("gate", "$.verdict"),
4308 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4309 ],
4310 };
4311 minimal_bp(agent, flow)
4312 }
4313
4314 fn named_agent(name: &str, verdict: Option<VerdictContract>) -> AgentDef {
4318 AgentDef {
4319 name: name.to_string(),
4320 ..gate_agent(verdict)
4321 }
4322 }
4323
4324 fn bp_with_two_unhandled_agents() -> Blueprint {
4328 let flow = FlowNode::Seq {
4329 children: vec![
4330 step("researcher", "$.researcher_verdict"),
4331 step("reviewer", "$.reviewer_verdict"),
4332 branch(eq_cond("$.researcher_verdict", "BLOCKED"), noop(), noop()),
4333 branch(eq_cond("$.reviewer_verdict", "BLOCKED"), noop(), noop()),
4334 ],
4335 };
4336 let mut bp = minimal_bp(
4337 named_agent("researcher", Some(body_contract(&["PASS", "BLOCKED"]))),
4338 flow,
4339 );
4340 bp.agents.push(named_agent(
4341 "reviewer",
4342 Some(body_contract(&["PASS", "BLOCKED"])),
4343 ));
4344 bp
4345 }
4346
4347 #[test]
4352 fn agent_lints_deny_rejects_only_the_declaring_agent() {
4353 let mut bp = bp_with_two_unhandled_agents();
4354 bp.agents[0].lints = lints(&[(
4355 "verdict-value-unhandled",
4356 mlua_swarm_schema::LintSetting::Deny,
4357 )]);
4358 match Compiler::new(registry_with_echo()).compile(&bp) {
4359 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4360 assert_eq!(agent, "researcher", "the sibling only warns");
4361 assert_eq!(value, "PASS");
4362 }
4363 Err(other) => {
4364 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4365 }
4366 Ok(_) => panic!(
4367 "expected compile-time rejection under \
4368 agents[0].lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4369 ),
4370 }
4371 }
4372
4373 #[test]
4377 fn agent_allow_beats_blueprint_deny_for_that_agent() {
4378 let mut bp = bp_with_two_unhandled_agents();
4379 bp.metadata.lints = lints(&[(
4380 "verdict-value-unhandled",
4381 mlua_swarm_schema::LintSetting::Deny,
4382 )]);
4383 bp.agents[0].lints = lints(&[(
4384 "verdict-value-unhandled",
4385 mlua_swarm_schema::LintSetting::Allow,
4386 )]);
4387 match Compiler::new(registry_with_echo()).compile(&bp) {
4388 Err(CompileError::VerdictValueUnhandled { agent, .. }) => {
4389 assert_eq!(
4390 agent, "reviewer",
4391 "the allowing agent is silenced; the sibling still denies"
4392 );
4393 }
4394 Err(other) => {
4395 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4396 }
4397 Ok(_) => panic!("the sibling agent's Blueprint-level deny must still reject"),
4398 }
4399 }
4400
4401 #[test]
4405 fn strict_flag_wins_over_agent_lints_allow() {
4406 let mut bp = bp_with_unhandled_value();
4407 bp.metadata.strict_verdict_handling = Some(true);
4408 bp.agents[0].lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4409 match Compiler::new(registry_with_echo()).compile(&bp) {
4410 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(agent, "gate"),
4411 Err(other) => {
4412 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4413 }
4414 Ok(_) => panic!(
4415 "strict_verdict_handling=Some(true) must still reject under an agent-level allow"
4416 ),
4417 }
4418 }
4419
4420 #[test]
4423 fn agent_category_key_reaches_the_kind() {
4424 let mut bp = bp_with_two_unhandled_agents();
4425 bp.agents[0].lints =
4426 lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4427 match Compiler::new(registry_with_echo()).compile(&bp) {
4428 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(
4429 agent, "researcher",
4430 "a category: group deny must reach the kind it covers, on the declaring agent"
4431 ),
4432 Err(other) => {
4433 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4434 }
4435 Ok(_) => panic!("expected compile-time rejection under an agent-level category deny"),
4436 }
4437 }
4438
4439 #[test]
4442 fn agent_without_lints_inherits_the_blueprint_layer() {
4443 let mut bp = bp_with_two_unhandled_agents();
4444 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4445 assert!(
4446 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4447 "a Blueprint-wide allow covers every agent that declares nothing"
4448 );
4449
4450 let gates = resolve_unhandled_verdict_gates(&bp);
4451 assert_eq!(gates.for_agent("reviewer"), UnhandledVerdictGate::Silence);
4452 assert!(gates.all_silent());
4453
4454 bp.agents[0].lints = lints(&[(
4455 "verdict-value-unhandled",
4456 mlua_swarm_schema::LintSetting::Warn,
4457 )]);
4458 let gates = resolve_unhandled_verdict_gates(&bp);
4459 assert_eq!(
4460 gates.for_agent("researcher"),
4461 UnhandledVerdictGate::Warn,
4462 "the agent's own layer wins over the Blueprint's allow"
4463 );
4464 assert_eq!(
4465 gates.for_agent("reviewer"),
4466 UnhandledVerdictGate::Silence,
4467 "the sibling keeps the Blueprint layer"
4468 );
4469 assert!(!gates.all_silent());
4470 }
4471
4472 #[test]
4476 fn lints_deny_rejects_unhandled_declared_value() {
4477 let mut bp = bp_with_unhandled_value();
4478 bp.metadata.lints = lints(&[(
4479 "verdict-value-unhandled",
4480 mlua_swarm_schema::LintSetting::Deny,
4481 )]);
4482 match Compiler::new(registry_with_echo()).compile(&bp) {
4483 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4484 assert_eq!(agent, "gate");
4485 assert_eq!(value, "PASS");
4486 }
4487 Err(other) => {
4488 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4489 }
4490 Ok(_) => panic!(
4491 "expected compile-time rejection under \
4492 metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4493 ),
4494 }
4495 }
4496
4497 #[test]
4500 fn lints_category_deny_rejects_unhandled_declared_value() {
4501 let mut bp = bp_with_unhandled_value();
4502 bp.metadata.lints = lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4503 assert!(
4504 matches!(
4505 Compiler::new(registry_with_echo()).compile(&bp),
4506 Err(CompileError::VerdictValueUnhandled { .. })
4507 ),
4508 "a category: group deny must reach the kind it covers"
4509 );
4510 }
4511
4512 #[test]
4516 fn lints_allow_compiles_and_silences_the_warn() {
4517 let mut bp = bp_with_unhandled_value();
4518 bp.metadata.lints = lints(&[(
4519 "verdict-value-unhandled",
4520 mlua_swarm_schema::LintSetting::Allow,
4521 )]);
4522 assert!(
4523 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4524 "an allowed lint must never reject the compile"
4525 );
4526 assert_eq!(
4527 resolve_unhandled_verdict_gate(&bp.metadata),
4528 UnhandledVerdictGate::Silence
4529 );
4530 }
4531
4532 #[test]
4536 fn strict_flag_wins_over_lints_allow() {
4537 let mut bp = bp_with_unhandled_value();
4538 bp.metadata.strict_verdict_handling = Some(true);
4539 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4540 assert!(
4541 matches!(
4542 Compiler::new(registry_with_echo()).compile(&bp),
4543 Err(CompileError::VerdictValueUnhandled { .. })
4544 ),
4545 "strict_verdict_handling=Some(true) must still reject under a lints allow"
4546 );
4547 }
4548
4549 #[test]
4552 fn unhandled_verdict_gate_resolution_table() {
4553 use mlua_swarm_schema::LintSetting;
4554
4555 let gate = |strict, map| {
4556 resolve_unhandled_verdict_gate(&BlueprintMetadata {
4557 strict_verdict_handling: strict,
4558 lints: map,
4559 ..Default::default()
4560 })
4561 };
4562 let kind = "verdict-value-unhandled";
4563
4564 assert_eq!(gate(None, None), UnhandledVerdictGate::Warn);
4565 assert_eq!(gate(Some(false), None), UnhandledVerdictGate::Warn);
4566 assert_eq!(gate(Some(true), None), UnhandledVerdictGate::Deny);
4567 assert_eq!(
4568 gate(None, lints(&[(kind, LintSetting::Deny)])),
4569 UnhandledVerdictGate::Deny
4570 );
4571 assert_eq!(
4572 gate(None, lints(&[(kind, LintSetting::Warn)])),
4573 UnhandledVerdictGate::Warn
4574 );
4575 assert_eq!(
4576 gate(None, lints(&[(kind, LintSetting::Allow)])),
4577 UnhandledVerdictGate::Silence
4578 );
4579 assert_eq!(
4580 gate(Some(true), lints(&[(kind, LintSetting::Allow)])),
4581 UnhandledVerdictGate::Deny,
4582 "strict wins over allow"
4583 );
4584 assert_eq!(
4586 gate(
4587 None,
4588 lints(&[
4589 (kind, LintSetting::Allow),
4590 ("category:suspicious", LintSetting::Deny),
4591 ])
4592 ),
4593 UnhandledVerdictGate::Silence
4594 );
4595 assert_eq!(
4598 gate(None, lints(&[("no-such-lint", LintSetting::Deny)])),
4599 UnhandledVerdictGate::Warn
4600 );
4601 }
4602
4603 #[test]
4607 fn lints_never_soften_other_compile_errors() {
4608 let mut bp = bp_with_unhandled_value();
4609 bp.agents.push(gate_agent(None));
4610 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4611 assert!(
4612 matches!(
4613 Compiler::new(registry_with_echo()).compile(&bp),
4614 Err(CompileError::DuplicateAgent(name)) if name == "gate"
4615 ),
4616 "an `all` allow must not suppress a compile hard error"
4617 );
4618 }
4619
4620 #[test]
4627 fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
4628 let agent = gate_agent(None);
4629 let flow = FlowNode::Seq {
4630 children: vec![
4631 step("gate", "$.verdict"),
4632 FlowNode::Loop {
4633 counter: Expr::Path {
4634 at: "$.n".parse().expect("literal test path"),
4635 },
4636 cond: eq_cond("$.verdict", "BLOCKED"),
4637 body: Box::new(step("gate", "$.verdict")),
4638 max: 3,
4639 },
4640 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4641 ],
4642 };
4643 let bp = minimal_bp(agent, flow);
4644 let compiled = Compiler::new(registry_with_echo())
4645 .compile(&bp)
4646 .expect("a verdict-omitted Blueprint must compile unchanged");
4647 assert!(
4648 compiled.router.verdict_contracts.is_empty(),
4649 "no agent declared a verdict contract"
4650 );
4651 }
4652
4653 #[test]
4660 fn every_compile_error_diagnostic_kind_is_a_declared_lint() {
4661 let kinds = [
4662 "bound-agent-resolution",
4663 "unknown-agent-kind",
4664 "invalid-agent-spec",
4665 "worker-binding-missing",
4666 "unresolved-agent-ref",
4667 "duplicate-agent-name",
4668 "unresolved-operator-ref",
4669 "unresolved-meta-ref",
4670 "step-naming-collision",
4671 "invalid-projection-placement",
4672 "unresolved-audit-agent",
4673 "verdict-channel-mismatch",
4674 "verdict-value-not-in-contract",
4675 "verdict-value-unhandled",
4676 "removed-spawner-hint",
4677 ];
4678 for kind in kinds {
4679 assert!(
4680 mlua_swarm_diag::lint_decl(kind).is_some(),
4681 "kind '{kind}' emitted by From<&CompileError> has no LINT_DECLS entry"
4682 );
4683 }
4684 }
4685
4686 #[test]
4687 fn invalid_spec_with_worker_binding_prefix_specializes_the_diagnostic_kind() {
4688 let err = CompileError::InvalidSpec {
4692 name: "greeter".into(),
4693 msg: format!("{WORKER_BINDING_REQUIRED_MSG_PREFIX}. Fix by either: (a) ..."),
4694 };
4695 let d = mlua_swarm_diag::Diagnostic::from(&err);
4696 assert_eq!(d.kind, "worker-binding-missing");
4697 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4698 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4699 assert!(d.message.contains("greeter"));
4700 let suggestion = d
4701 .suggestion
4702 .expect("specialized arm must carry a suggestion");
4703 assert!(suggestion.patch.contains("backend = \"ws_operator\""));
4704 assert_eq!(
4705 suggestion.applicability,
4706 mlua_swarm_diag::Applicability::HasPlaceholders
4707 );
4708 assert_eq!(
4709 d.docs_ref.expect("docs_ref must be set").uri,
4710 "mse://guides/bp-dsl-templates"
4711 );
4712 match d.span.expect("span must be set").element {
4713 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "greeter"),
4714 other => panic!("expected Agent span, got {other:?}"),
4715 }
4716 }
4717
4718 fn bp_with_hint_layers(layers: &[&str]) -> Blueprint {
4728 let mut bp = minimal_bp(gate_agent(None), step("gate", "$.verdict"));
4729 bp.spawner_hints = mlua_swarm_schema::SpawnerHints {
4730 layers: layers.iter().map(|s| s.to_string()).collect(),
4731 };
4732 bp
4733 }
4734
4735 #[test]
4736 fn declaring_the_removed_operator_delegate_layer_fails_the_compile() {
4737 let bp = bp_with_hint_layers(&["operator_delegate"]);
4738 match Compiler::new(registry_with_echo()).compile(&bp) {
4739 Err(CompileError::RemovedSpawnerHint { key, reason }) => {
4740 assert_eq!(key, "operator_delegate");
4741 assert!(
4742 reason.contains("Run.current") && reason.contains("system"),
4743 "the reason must name both defects the removal was for, so the error \
4744 alone explains why: {reason}"
4745 );
4746 }
4747 Err(other) => panic!("expected RemovedSpawnerHint, got: {other}"),
4748 Ok(_) => panic!(
4749 "a Blueprint declaring the removed operator_delegate layer must not compile — \
4750 otherwise the layer silently stops applying"
4751 ),
4752 }
4753 }
4754
4755 #[test]
4761 fn an_unknown_but_not_removed_layer_key_still_compiles() {
4762 let bp = bp_with_hint_layers(&["main_ai", "some_deployment_specific_layer"]);
4763 Compiler::new(registry_with_echo())
4764 .compile(&bp)
4765 .expect("unknown (as opposed to removed) hint keys stay leniently skipped");
4766 }
4767
4768 #[test]
4769 fn a_blueprint_declaring_no_layers_compiles() {
4770 let bp = bp_with_hint_layers(&[]);
4771 Compiler::new(registry_with_echo())
4772 .compile(&bp)
4773 .expect("the all-clear case must produce no finding at all");
4774 }
4775
4776 #[test]
4777 fn removed_spawner_hint_projects_a_migration_diagnostic_naming_the_replacement() {
4778 let err = CompileError::RemovedSpawnerHint {
4779 key: "operator_delegate".into(),
4780 reason: "it could not follow a handover".into(),
4781 };
4782 let d = mlua_swarm_diag::Diagnostic::from(&err);
4783
4784 assert_eq!(d.kind, "removed-spawner-hint");
4785 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4786 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4787
4788 let decl = mlua_swarm_diag::lint_decl("removed-spawner-hint")
4792 .expect("the kind must be declared in LINT_DECLS");
4793 assert_eq!(decl.category, mlua_swarm_diag::LintCategory::Migration);
4794
4795 let help = d.help.as_ref().expect("help must name the replacement");
4797 assert!(
4798 help.contains("operators[]")
4799 && help.contains("spec.operator_ref")
4800 && help.contains("operator_sid"),
4801 "the help line must name all three parts of the AgentSpec axis an author has to \
4802 write, not just say the old one is gone: {help}"
4803 );
4804
4805 let suggestion = d.suggestion.expect("a concrete patch must be attached");
4813 assert_eq!(
4814 suggestion,
4815 mlua_swarm_diag::removed_spawner_hint_suggestion()
4816 );
4817 assert!(
4818 suggestion.patch.contains("\"operator_ref\""),
4819 "whatever else the shared patch says, it has to show the field an author \
4820 must add: {}",
4821 suggestion.patch
4822 );
4823 assert_eq!(
4824 suggestion.applicability,
4825 mlua_swarm_diag::Applicability::HasPlaceholders
4828 );
4829
4830 assert_eq!(
4831 d.docs_ref.expect("docs_ref must be set").uri,
4832 "mse://guides/blueprint-authoring"
4833 );
4834 assert!(matches!(
4835 d.span.expect("span must be set").element,
4836 mlua_swarm_diag::DiagElement::BlueprintRoot
4837 ));
4838 }
4839
4840 #[test]
4841 fn generic_invalid_spec_maps_to_the_generic_kind() {
4842 let err = CompileError::InvalidSpec {
4843 name: "solo".into(),
4844 msg: "operator spec: 'operator_ref' (string) required".into(),
4845 };
4846 let d = mlua_swarm_diag::Diagnostic::from(&err);
4847 assert_eq!(d.kind, "invalid-agent-spec");
4848 assert!(
4849 d.suggestion.is_none(),
4850 "generic arm carries no canned patch"
4851 );
4852 }
4853
4854 #[test]
4855 fn verdict_value_not_in_contract_diagnostic_carries_suggestion_and_span() {
4856 let err = CompileError::VerdictValueNotInContract {
4857 where_: "Branch cond".into(),
4858 agent: "review".into(),
4859 value: "NOT_DECLARED".into(),
4860 values: vec!["PASS".into(), "BLOCKED".into()],
4861 };
4862 let d = mlua_swarm_diag::Diagnostic::from(&err);
4863 assert_eq!(d.kind, "verdict-value-not-in-contract");
4864 assert!(d.message.contains("NOT_DECLARED"));
4865 assert!(d.suggestion.is_some());
4866 match d.span.expect("span must be set").element {
4867 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "review"),
4868 other => panic!("expected Agent span, got {other:?}"),
4869 }
4870 }
4871}
4872
4873#[cfg(test)]
4875mod subprocess_embed_compile_tests {
4876 use super::*;
4877 use mlua_swarm_schema::{current_schema_version, SubprocessDef, SubprocessOverrides};
4878
4879 fn subprocess_agent(name: &str, runner: Option<Runner>) -> AgentDef {
4880 AgentDef {
4881 name: name.to_string(),
4882 kind: AgentKind::Subprocess,
4883 spec: serde_json::json!({}),
4884 profile: Some(AgentProfile {
4885 system_prompt: "you are a headless worker".to_string(),
4886 model: Some("profile-model".to_string()),
4887 tools: vec!["Read".to_string()],
4888 ..Default::default()
4889 }),
4890 meta: None,
4891 runner,
4892 runner_ref: None,
4893 verdict: None,
4894 lints: None,
4895 }
4896 }
4897
4898 fn echo_def(name: &str) -> SubprocessDef {
4899 SubprocessDef {
4900 name: name.to_string(),
4901 argv: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
4902 stdin: Some("{prompt}".to_string()),
4903 env: Default::default(),
4904 cwd: None,
4905 output: None,
4906 stream_mode: None,
4907 }
4908 }
4909
4910 fn bp_with(agents: Vec<AgentDef>, subprocesses: Vec<SubprocessDef>) -> Blueprint {
4911 Blueprint {
4912 schema_version: current_schema_version(),
4913 id: "gh83-ut".into(),
4914 flow: FlowNode::Seq { children: vec![] },
4915 agents,
4916 operators: vec![],
4917 metas: vec![],
4918 hints: Default::default(),
4919 strategy: Default::default(),
4920 metadata: BlueprintMetadata::default(),
4921 spawner_hints: Default::default(),
4922 default_agent_kind: AgentKind::Operator,
4923 default_operator_kind: None,
4924 default_init_ctx: None,
4925 default_agent_ctx: None,
4926 default_context_policy: None,
4927 projection_placement: None,
4928 audits: vec![],
4929 degradation_policy: None,
4930 runners: vec![],
4931 default_runner: None,
4932 subprocesses,
4933 check_policy: None,
4934 blueprint_ref_includes: vec![],
4935 }
4936 }
4937
4938 fn subprocess_runner(template: &str) -> Runner {
4939 Runner::Subprocess {
4940 template: template.to_string(),
4941 overrides: SubprocessOverrides::default(),
4942 }
4943 }
4944
4945 #[test]
4946 fn validate_placeholders_accepts_closed_set_and_json_braces() {
4947 for ok in [
4948 "{system} {system_file} {prompt} {model} {tools_csv} {work_dir} {task_id} {attempt}",
4949 r#"echo '{"result": "ok", "nested": {"a": 1}}'"#,
4950 "no placeholders at all",
4951 "unmatched { brace",
4952 ] {
4953 validate_embed_placeholders(ok, "ut").expect("must be accepted");
4954 }
4955 }
4956
4957 #[test]
4958 fn validate_placeholders_rejects_unknown_token() {
4959 let err = validate_embed_placeholders("--flag {evil}", "argv[1]").unwrap_err();
4960 assert!(err.contains("'{evil}'"), "token named: {err}");
4961 assert!(err.contains("closed set"), "closed set listed: {err}");
4962 }
4963
4964 #[test]
4968 fn validate_placeholders_descends_into_literal_braces() {
4969 validate_embed_placeholders(r#"{"task": "{prompt}"}"#, "stdin")
4970 .expect("nested closed-set token must be accepted");
4971 let err = validate_embed_placeholders(r#"{"task": "{evil}"}"#, "stdin").unwrap_err();
4972 assert!(
4973 err.contains("'{evil}'"),
4974 "nested unknown token caught: {err}"
4975 );
4976 }
4977
4978 #[test]
4979 fn hint_resolution_finds_declared_template() {
4980 let agent = subprocess_agent("headless", Some(subprocess_runner("echo")));
4981 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4982 let hint = resolve_subprocess_template_hint(&bp, &agent)
4983 .expect("resolves")
4984 .expect("Runner::Subprocess must synthesize a hint");
4985 assert_eq!(hint[SUBPROCESS_TEMPLATE_HINT_KEY]["name"], "echo");
4986 assert!(hint.get(SUBPROCESS_OVERRIDES_HINT_KEY).is_some());
4987 }
4988
4989 #[test]
4990 fn hint_resolution_unknown_template_is_invalid_spec() {
4991 let agent = subprocess_agent("headless", Some(subprocess_runner("nope")));
4992 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4993 let err = resolve_subprocess_template_hint(&bp, &agent).unwrap_err();
4994 let msg = format!("{err}");
4995 assert!(msg.contains("'nope'"), "missing template named: {msg}");
4996 assert!(msg.contains("echo"), "defined templates listed: {msg}");
4997 }
4998
4999 #[test]
5000 fn hint_resolution_none_without_subprocess_runner() {
5001 let agent = subprocess_agent("headless", None);
5002 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5003 let hint = resolve_subprocess_template_hint(&bp, &agent).expect("resolves");
5004 assert!(hint.is_none(), "spec-based agents keep the historical path");
5005 }
5006
5007 fn agent_block_agent(name: &str, runner: Option<Runner>, profile_tools: &[&str]) -> AgentDef {
5014 AgentDef {
5015 name: name.to_string(),
5016 kind: AgentKind::AgentBlock,
5017 spec: serde_json::json!({}),
5018 profile: Some(AgentProfile {
5019 system_prompt: "you are an in-process auditor".to_string(),
5020 tools: profile_tools.iter().map(|t| t.to_string()).collect(),
5021 ..Default::default()
5022 }),
5023 meta: None,
5024 runner,
5025 runner_ref: None,
5026 verdict: None,
5027 lints: None,
5028 }
5029 }
5030
5031 fn agent_block_runner(tools: &[&str]) -> Runner {
5032 Runner::AgentBlockInProcess {
5033 tools: tools.iter().map(|t| t.to_string()).collect(),
5034 }
5035 }
5036
5037 #[test]
5043 fn agent_block_runner_tools_are_projected_over_profile_tools() {
5044 let agent = agent_block_agent(
5045 "auditor",
5046 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5047 &["Read"],
5048 );
5049 let bp = bp_with(vec![agent], vec![]);
5050 let bound = resolve_bound_agents(&bp).expect("binds");
5051 let effective = materialize_bound_blueprint(&bp, &bound);
5052 assert_eq!(
5053 effective.agents[0].profile.as_ref().unwrap().tools,
5054 vec!["mcp__outline__list_docs".to_string()],
5055 "the declared Runner tools replace profile.tools (['Read'])"
5056 );
5057 }
5058
5059 #[test]
5063 fn agent_block_projection_distinguishes_declared_empty_from_absent() {
5064 let declared = agent_block_agent("auditor", Some(agent_block_runner(&[])), &["Read"]);
5065 let bp = bp_with(vec![declared], vec![]);
5066 let bound = resolve_bound_agents(&bp).expect("binds");
5067 let effective = materialize_bound_blueprint(&bp, &bound);
5068 assert!(
5069 effective.agents[0]
5070 .profile
5071 .as_ref()
5072 .unwrap()
5073 .tools
5074 .is_empty(),
5075 "empty means enforced-empty, not 'unset'"
5076 );
5077
5078 let absent = agent_block_agent("auditor", None, &["Read"]);
5079 let bp = bp_with(vec![absent], vec![]);
5080 let bound = resolve_bound_agents(&bp).expect("binds");
5081 let effective = materialize_bound_blueprint(&bp, &bound);
5082 assert_eq!(
5083 effective.agents[0].profile.as_ref().unwrap().tools,
5084 vec!["Read".to_string()],
5085 "no Runner declared → the agent.md tools line stands"
5086 );
5087 }
5088
5089 #[test]
5096 fn compile_rejects_script_mode_with_a_declared_mcp_grant() {
5097 let mut agent = agent_block_agent(
5098 "auditor",
5099 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5100 &[],
5101 );
5102 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5103 let mut bp = bp_with(vec![agent], vec![]);
5104 bp.strategy.strict_refs = false;
5105
5106 let mut registry = SpawnerRegistry::new();
5107 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5108 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5109 );
5110 let err = match Compiler::new(registry).compile(&bp) {
5112 Err(e) => e,
5113 Ok(_) => panic!("script mode + declared MCP grant must not compile"),
5114 };
5115 let msg = format!("{err}");
5116 assert!(msg.contains("script_path"), "names the trigger: {msg}");
5117 assert!(
5118 msg.contains("mcp__outline__list_docs"),
5119 "names the unenforceable tools: {msg}"
5120 );
5121 }
5122
5123 #[test]
5127 fn compile_accepts_script_mode_with_only_inert_tools() {
5128 let mut agent = agent_block_agent("auditor", None, &["Read", "WebSearch"]);
5129 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5130 let mut bp = bp_with(vec![agent], vec![]);
5131 bp.strategy.strict_refs = false;
5132
5133 let mut registry = SpawnerRegistry::new();
5134 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5135 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5136 );
5137 if let Err(e) = Compiler::new(registry).compile(&bp) {
5138 panic!("inert tools must not trip the MCP-grant guard: {e}");
5139 }
5140 }
5141
5142 #[test]
5143 fn build_embed_rejects_unknown_placeholder() {
5144 let agent = subprocess_agent("headless", None);
5145 let mut def = echo_def("echo");
5146 def.argv.push("--x={evil}".to_string());
5147 let err = SubprocessProcessSpawnerFactory::build_embed(
5148 &agent,
5149 &serde_json::to_value(&def).unwrap(),
5150 None,
5151 )
5152 .unwrap_err();
5153 assert!(format!("{err}").contains("'{evil}'"));
5154 }
5155
5156 #[test]
5157 fn build_embed_rejects_output_with_stream_mode() {
5158 let agent = subprocess_agent("headless", None);
5159 let mut def = echo_def("echo");
5160 def.stream_mode = Some("ndjson_lines".to_string());
5161 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5162 format: Some("json".to_string()),
5163 result_ptr: None,
5164 ok_from: None,
5165 stats: None,
5166 });
5167 let err = SubprocessProcessSpawnerFactory::build_embed(
5168 &agent,
5169 &serde_json::to_value(&def).unwrap(),
5170 None,
5171 )
5172 .unwrap_err();
5173 assert!(format!("{err}").contains("plain-mode"));
5174 }
5175
5176 #[test]
5177 fn build_embed_rejects_malformed_result_ptr_and_ok_from() {
5178 let agent = subprocess_agent("headless", None);
5179 let mut def = echo_def("echo");
5180 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5181 format: None,
5182 result_ptr: Some("result".to_string()),
5183 ok_from: None,
5184 stats: None,
5185 });
5186 let err = SubprocessProcessSpawnerFactory::build_embed(
5187 &agent,
5188 &serde_json::to_value(&def).unwrap(),
5189 None,
5190 )
5191 .unwrap_err();
5192 assert!(format!("{err}").contains("JSON Pointer"));
5193
5194 let mut def = echo_def("echo");
5195 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5196 format: None,
5197 result_ptr: None,
5198 ok_from: Some("status".to_string()),
5199 stats: None,
5200 });
5201 let err = SubprocessProcessSpawnerFactory::build_embed(
5202 &agent,
5203 &serde_json::to_value(&def).unwrap(),
5204 None,
5205 )
5206 .unwrap_err();
5207 assert!(format!("{err}").contains("exit_code"));
5208 }
5209
5210 #[test]
5211 fn build_embed_bakes_profile_with_override_precedence() {
5212 let agent = subprocess_agent("headless", None);
5213 let def = echo_def("echo");
5214 let overrides = SubprocessOverrides {
5215 model: Some("override-model".to_string()),
5216 tools: vec!["Bash".to_string(), "Write".to_string()],
5217 cwd: Some("/tmp/override-wd".to_string()),
5218 };
5219 let sp = SubprocessProcessSpawnerFactory::build_embed(
5220 &agent,
5221 &serde_json::to_value(&def).unwrap(),
5222 Some(&serde_json::to_value(&overrides).unwrap()),
5223 )
5224 .expect("builds");
5225 let embed = sp.embed.as_ref().expect("embed template baked");
5226 assert_eq!(embed.model.as_deref(), Some("override-model"));
5227 assert_eq!(embed.tools_csv, "Bash,Write");
5228 assert_eq!(embed.cwd.as_deref(), Some("/tmp/override-wd"));
5229 assert_eq!(
5230 embed.system_prompt.as_deref(),
5231 Some("you are a headless worker")
5232 );
5233 }
5234}