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(
83 "agent '{agent}' declares max_concurrency = 0, so none of its steps could ever run; \
84 use a positive limit, or omit the field for no limit"
85 )]
86 InvalidMaxConcurrency {
87 agent: String,
89 },
90 #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
93 UnresolvedOperatorRef {
94 agent: String,
96 op_ref: String,
98 defined: Vec<String>,
101 },
102 #[error(
123 "spawner_hints.layers declares '{key}' but that layer has been removed: {reason}. \
124 Drop the key and route through the AgentSpec axis instead — declare the seat in \
125 `operators[]`, point the agent at it with `spec.operator_ref`, and pin its holder \
126 per launch with `operator_sid`"
127 )]
128 RemovedSpawnerHint {
129 key: String,
131 reason: String,
135 },
136 #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
140 UnresolvedMetaRef {
141 where_: String,
145 meta_ref: String,
147 defined: Vec<String>,
150 },
151 #[error("StepNaming collision: {0}")]
157 StepNamingCollision(#[from] StepNamingError),
158 #[error("invalid projection_placement: {0}")]
165 InvalidProjectionPlacement(#[from] ProjectionPlacementError),
166 #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
171 UnresolvedAuditAgent {
172 agent: String,
174 defined: Vec<String>,
177 },
178 #[error(
187 "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
188 addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
189 BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
190 )]
191 VerdictChannelMismatch {
192 where_: String,
195 agent: String,
197 expected_channel: String,
199 actual_shape: String,
202 },
203 #[error(
208 "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
209 values {values:?}"
210 )]
211 VerdictValueNotInContract {
212 where_: String,
215 agent: String,
218 value: String,
223 values: Vec<String>,
226 },
227 #[error(
239 "agent '{agent}' declares verdict value '{value}' but no downstream Branch/Loop \
240 cond references it (declared: {declared_values:?}, at step '{step_ref}') — either \
241 handle the value downstream or drop it from `verdict.values`"
242 )]
243 VerdictValueUnhandled {
244 agent: String,
247 value: String,
249 declared_values: Vec<String>,
252 step_ref: String,
257 },
258}
259
260pub const WORKER_BINDING_REQUIRED_MSG_PREFIX: &str =
269 "profile.worker_binding is required for this operator backend";
270
271pub fn removed_spawner_hint_reason(key: &str) -> Option<&'static str> {
284 match key {
285 "operator_delegate" => Some(
286 "the Blueprint-global Operator delegate axis was removed because it could not \
287 follow a seat handover (it resolved its destination from the launch-time \
288 `operator_backend_id`, never from `Run.current`) and could not carry an \
289 agent's `system_prompt` (it had no per-agent spawner, so it passed `system: \
290 None` and never baked one for `/v1/worker/prompt`)",
291 ),
292 _ => None,
293 }
294}
295
296impl From<&CompileError> for mlua_swarm_diag::Diagnostic {
314 fn from(err: &CompileError) -> Self {
315 use mlua_swarm_diag::{
316 Applicability, DiagElement, DiagLevel, DiagSpan, DiagStage, Diagnostic, DocsRef,
317 Suggestion,
318 };
319 let base = |kind: &'static str| {
320 Diagnostic::new(
321 kind,
322 DiagStage::CompileLint,
323 DiagLevel::Error,
324 err.to_string(),
325 )
326 };
327 let agent_span = |name: &str| DiagSpan {
328 element: DiagElement::Agent {
329 name: name.to_string(),
330 },
331 json_path: Some(format!("$.agents[?(@.name=='{name}')]")),
332 };
333 match err {
334 CompileError::BoundAgent(_) => base("bound-agent-resolution"),
335 CompileError::UnknownKind(_) => base("unknown-agent-kind").with_help(
336 "register a SpawnerFactory for this kind, or disable strategy.strict_kind",
337 ),
338 CompileError::InvalidSpec { name, msg }
339 if msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX) =>
340 {
341 Diagnostic::new(
342 "worker-binding-missing",
343 DiagStage::CompileLint,
344 DiagLevel::Error,
345 format!(
346 "operator agent '{name}' has no explicit Runner or legacy \
347 `profile.worker_binding`"
348 ),
349 )
350 .with_note(msg.clone())
351 .with_suggestion(Suggestion {
352 msg: "add an explicit Runner (or legacy profile.worker_binding)".into(),
353 patch: "runner = { backend = \"ws_operator\", variant = \"claude\", \
354 tools = {} }"
355 .into(),
356 applicability: Applicability::HasPlaceholders,
357 })
358 .with_docs_ref(DocsRef {
359 uri: "mse://guides/bp-dsl-templates",
360 anchor: None,
361 })
362 .with_span(agent_span(name))
363 }
364 CompileError::InvalidSpec { name, .. } => {
365 base("invalid-agent-spec").with_span(agent_span(name))
366 }
367 CompileError::UnresolvedRef(ref_) => base("unresolved-agent-ref").with_span(DiagSpan {
368 element: DiagElement::Step { ref_: ref_.clone() },
369 json_path: None,
370 }),
371 CompileError::DuplicateAgent(name) => {
372 base("duplicate-agent-name").with_span(agent_span(name))
373 }
374 CompileError::InvalidMaxConcurrency { agent } => base("invalid-max-concurrency")
375 .with_suggestion(Suggestion {
376 msg: "use a positive limit, or drop the field for no limit".into(),
377 patch: "\"max_concurrency\": 2".into(),
378 applicability: Applicability::HasPlaceholders,
379 })
380 .with_docs_ref(DocsRef {
381 uri: "mse://guides/blueprint-authoring",
382 anchor: Some("#agent-max-concurrency"),
383 })
384 .with_span(DiagSpan {
385 element: DiagElement::Agent {
386 name: agent.clone(),
387 },
388 json_path: Some(format!("$.agents[?(@.name=='{agent}')].max_concurrency")),
389 }),
390 CompileError::UnresolvedOperatorRef { agent, defined, .. } => {
391 base("unresolved-operator-ref")
392 .with_note(format!("declared OperatorDef names: {defined:?}"))
393 .with_span(agent_span(agent))
394 }
395 CompileError::RemovedSpawnerHint { key, .. } => base("removed-spawner-hint")
396 .with_note(
397 "`service::linker::link` skips hint keys a deployment does not install, \
398 so leaving this key in place would drop the capability silently rather \
399 than report it"
400 .to_string(),
401 )
402 .with_help(
403 "route the spawn through the AgentSpec axis: the seat is declared once in \
404 `operators[]`, each agent that should reach an Operator selects it with \
405 `spec.operator_ref`, and the launch names who holds the seat for that run \
406 via `operator_sid` (which a later handover can move without recompiling)",
407 )
408 .with_suggestion(mlua_swarm_diag::removed_spawner_hint_suggestion())
414 .with_docs_ref(DocsRef {
415 uri: "mse://guides/blueprint-authoring",
416 anchor: Some("removed-spawner-hint-layers"),
417 })
418 .with_span(DiagSpan {
419 element: DiagElement::BlueprintRoot,
420 json_path: Some(format!("$.spawner_hints.layers[?(@=='{key}')]")),
421 }),
422 CompileError::UnresolvedMetaRef { defined, .. } => base("unresolved-meta-ref")
423 .with_note(format!("declared MetaDef names: {defined:?}")),
424 CompileError::StepNamingCollision(_) => base("step-naming-collision"),
425 CompileError::InvalidProjectionPlacement(_) => base("invalid-projection-placement")
426 .with_span(DiagSpan {
427 element: DiagElement::BlueprintRoot,
428 json_path: Some("$.projection_placement".into()),
429 }),
430 CompileError::UnresolvedAuditAgent { defined, .. } => base("unresolved-audit-agent")
431 .with_note(format!("declared AgentDef names: {defined:?}"))
432 .with_span(DiagSpan {
433 element: DiagElement::BlueprintRoot,
434 json_path: Some("$.audits".into()),
435 }),
436 CompileError::VerdictChannelMismatch { agent, .. } => base("verdict-channel-mismatch")
437 .with_help(
438 "see the \"Returning verdicts to drive BP flow\" guide's Pattern A \
439 (channel: \"body\") / Pattern B (channel: \"part\")",
440 )
441 .with_docs_ref(DocsRef {
442 uri: "mse://guides/blueprint-authoring",
443 anchor: None,
444 })
445 .with_span(agent_span(agent)),
446 CompileError::VerdictValueNotInContract { agent, .. } => {
447 base("verdict-value-not-in-contract")
448 .with_suggestion(Suggestion {
454 msg: "align the cond literal with the agent's declared verdict \
455 contract"
456 .into(),
457 patch: "either add the cond's literal to `agents[N].verdict.values`, \
458 or change the cond to a value that is already declared"
459 .into(),
460 applicability: Applicability::MaybeIncorrect,
461 })
462 .with_docs_ref(DocsRef {
463 uri: "mse://guides/blueprint-authoring",
464 anchor: None,
465 })
466 .with_span(agent_span(agent))
467 }
468 CompileError::VerdictValueUnhandled {
469 agent,
470 declared_values,
471 ..
472 } => base("verdict-value-unhandled")
473 .with_note(format!("declared verdict.values: {declared_values:?}"))
474 .with_help(
475 "either handle the value in a downstream Branch/Loop cond, or drop it \
476 from verdict.values",
477 )
478 .with_span(agent_span(agent)),
479 }
480 }
481}
482
483pub trait SpawnerFactory: Send + Sync {
495 fn build(
498 &self,
499 agent_def: &AgentDef,
500 hint: Option<&Value>,
501 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
502}
503
504pub trait SpawnerFactoryKind: SpawnerFactory {
520 const KIND: AgentKind;
523 type Worker: crate::worker::Worker;
530}
531
532#[derive(Clone)]
535pub struct SpawnerRegistry {
536 factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
537}
538
539impl SpawnerRegistry {
540 pub fn new() -> Self {
542 Self {
543 factories: HashMap::new(),
544 }
545 }
546 pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
555 let f: Arc<dyn SpawnerFactory> = factory;
556 self.factories.insert(F::KIND, f);
557 self
558 }
559}
560
561impl Default for SpawnerRegistry {
562 fn default() -> Self {
563 Self::new()
564 }
565}
566
567pub struct Compiler {
574 registry: SpawnerRegistry,
575 default_spawner: Option<Arc<dyn SpawnerAdapter>>,
576}
577
578pub struct CompiledBlueprint {
582 pub router: Arc<CompiledAgentTable>,
584 pub flow: FlowNode,
586 pub metadata: BlueprintMetadata,
588 pub step_naming: Arc<StepNaming>,
593 pub projection_placement: Arc<ProjectionPlacement>,
599}
600
601fn project_bound_agent_for_legacy_factories(bound: &BoundAgent) -> AgentDef {
602 let mut agent = bound.agent.clone();
603 match &bound.runner {
604 Some(Runner::WsOperator { variant, tools })
605 | Some(Runner::WsClaudeCode { variant, tools }) => {
606 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
607 profile.worker_binding = Some(variant.clone());
608 profile.tools = tools.clone();
609 }
610 Some(Runner::AgentBlockInProcess { tools }) => {
611 let profile = agent.profile.get_or_insert_with(AgentProfile::default);
612 profile.worker_binding = None;
613 profile.tools = tools.clone();
614 }
615 Some(Runner::Subprocess { .. }) => {}
620 None => {}
621 }
622 let meta = agent.meta.get_or_insert_with(Default::default);
623 meta.context_policy = bound.context_policy.clone();
624 agent
625}
626
627pub(crate) fn materialize_bound_blueprint(
630 bp: &Blueprint,
631 bound_agents: &[BoundAgent],
632) -> Blueprint {
633 let mut effective = bp.clone();
634 effective.agents = bound_agents
635 .iter()
636 .map(project_bound_agent_for_legacy_factories)
637 .collect();
638 effective.default_context_policy = None;
641 effective
642}
643
644impl Compiler {
645 pub fn new(registry: SpawnerRegistry) -> Self {
649 Self {
650 registry,
651 default_spawner: None,
652 }
653 }
654
655 pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
659 self.default_spawner = Some(sp);
660 self
661 }
662
663 pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
668 let bound_agents = resolve_bound_agents(bp)?;
669 self.compile_bound(bp, &bound_agents)
670 }
671
672 pub fn compile_bound(
685 &self,
686 bp: &Blueprint,
687 bound_agents: &[BoundAgent],
688 ) -> Result<CompiledBlueprint, CompileError> {
689 let effective = materialize_bound_blueprint(bp, bound_agents);
690 self.compile_resolved(&effective)
691 }
692
693 fn compile_resolved(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
694 let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
695 let mut seen: HashMap<String, ()> = HashMap::new();
696 let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
702
703 for key in &bp.spawner_hints.layers {
720 if let Some(reason) = removed_spawner_hint_reason(key) {
721 return Err(CompileError::RemovedSpawnerHint {
722 key: key.clone(),
723 reason: reason.to_string(),
724 });
725 }
726 }
727
728 let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
734 for ad in &bp.agents {
735 if !matches!(ad.kind, AgentKind::Operator) {
736 continue;
737 }
738 let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
739 if let Some(op_ref) = op_ref {
740 if !defined.iter().any(|n| n == op_ref) {
741 return Err(CompileError::UnresolvedOperatorRef {
742 agent: ad.name.clone(),
743 op_ref: op_ref.to_string(),
744 defined: defined.clone(),
745 });
746 }
747 }
748 }
750
751 let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
755 for ad in &bp.agents {
756 let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
757 if let Some(meta_ref) = meta_ref {
758 if !metas_defined.iter().any(|n| n == meta_ref) {
759 return Err(CompileError::UnresolvedMetaRef {
760 where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
761 meta_ref: meta_ref.clone(),
762 defined: metas_defined.clone(),
763 });
764 }
765 }
766 }
767 let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
773 collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
774 for (where_, meta_ref) in static_step_meta_refs {
775 if !metas_defined.iter().any(|n| n == &meta_ref) {
776 return Err(CompileError::UnresolvedMetaRef {
777 where_,
778 meta_ref,
779 defined: metas_defined.clone(),
780 });
781 }
782 }
783
784 let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
789 for audit in &bp.audits {
790 if !agents_defined.iter().any(|n| n == &audit.agent) {
791 return Err(CompileError::UnresolvedAuditAgent {
792 agent: audit.agent.clone(),
793 defined: agents_defined.clone(),
794 });
795 }
796 }
797
798 for ad in &bp.agents {
799 if seen.contains_key(&ad.name) {
800 return Err(CompileError::DuplicateAgent(ad.name.clone()));
801 }
802 seen.insert(ad.name.clone(), ());
803 if ad.max_concurrency == Some(0) {
804 return Err(CompileError::InvalidMaxConcurrency {
805 agent: ad.name.clone(),
806 });
807 }
808
809 if let Some(contract) = &ad.verdict {
815 verdict_contracts.insert(ad.name.clone(), contract.clone());
816 }
817
818 let factory = match self.registry.factories.get(&ad.kind) {
819 Some(f) => f.clone(),
820 None => {
821 if bp.strategy.strict_kind {
822 return Err(CompileError::UnknownKind(ad.kind.clone()));
823 } else {
824 tracing::warn!(
825 agent = %ad.name,
826 kind = ?ad.kind,
827 "no spawner factory registered for agent kind; \
828 dropping agent from routing table (strict_kind=false)"
829 );
830 continue;
831 }
832 }
833 };
834 let hint = bp.hints.per_agent.get(&ad.name);
835 let subprocess_hint = if ad.kind == AgentKind::Subprocess {
847 resolve_subprocess_template_hint(bp, ad)?
848 } else {
849 None
850 };
851 let spawner = factory.build(ad, subprocess_hint.as_ref().or(hint))?;
852 routes.insert(ad.name.clone(), spawner);
853 }
854
855 let unhandled_gates = resolve_unhandled_verdict_gates(bp);
875 verify_verdict_conds(&bp.flow, &verdict_contracts, &unhandled_gates)?;
876
877 if bp.strategy.strict_refs {
878 verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
879 }
880
881 let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
897 for warning in &step_naming_warnings {
898 tracing::warn!(
899 name = %warning.name,
900 first_step_ref = %warning.first_step_ref,
901 second_step_ref = %warning.second_step_ref,
902 "StepNaming: undeclared steps' canonical/alias names collide; \
903 the step whose own ref matches the name keeps it (data-plane priority)"
904 );
905 }
906
907 let projection_placement =
915 ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
916
917 let router = Arc::new(CompiledAgentTable {
918 routes,
919 default: self.default_spawner.clone(),
920 verdict_contracts,
921 });
922 Ok(CompiledBlueprint {
923 router,
924 flow: bp.flow.clone(),
925 metadata: bp.metadata.clone(),
926 step_naming: Arc::new(step_naming),
927 projection_placement: Arc::new(projection_placement),
928 })
929 }
930}
931
932fn verify_refs(
935 node: &FlowNode,
936 routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
937 has_default: bool,
938) -> Result<(), CompileError> {
939 let mut refs: Vec<String> = Vec::new();
940 collect_refs(node, &mut refs);
941 for r in refs {
942 if !routes.contains_key(&r) && !has_default {
943 return Err(CompileError::UnresolvedRef(r));
944 }
945 }
946 Ok(())
947}
948
949fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
950 match node {
951 FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
952 FlowNode::Seq { children } => {
953 for c in children {
954 collect_refs(c, out);
955 }
956 }
957 FlowNode::Branch { then_, else_, .. } => {
958 collect_refs(then_, out);
959 collect_refs(else_, out);
960 }
961 FlowNode::Fanout { body, .. } => collect_refs(body, out),
962 FlowNode::Loop { body, .. } => collect_refs(body, out),
963 FlowNode::Try { body, catch, .. } => {
964 collect_refs(body, out);
965 collect_refs(catch, out);
966 }
967 FlowNode::Let { .. } => {} }
969}
970
971fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
979 match node {
980 FlowNode::Step { ref_, in_, .. } => {
981 if let Expr::Lit { value } = in_ {
982 if let Some(meta_ref) = static_step_meta_ref(value) {
983 out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
984 }
985 }
986 }
987 FlowNode::Seq { children } => {
988 for c in children {
989 collect_step_meta_refs(c, out);
990 }
991 }
992 FlowNode::Branch { then_, else_, .. } => {
993 collect_step_meta_refs(then_, out);
994 collect_step_meta_refs(else_, out);
995 }
996 FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
997 FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
998 FlowNode::Try { body, catch, .. } => {
999 collect_step_meta_refs(body, out);
1000 collect_step_meta_refs(catch, out);
1001 }
1002 FlowNode::Let { .. } => {} }
1004}
1005
1006fn static_step_meta_ref(value: &Value) -> Option<String> {
1013 value
1014 .as_object()?
1015 .get("$step_meta")?
1016 .as_object()?
1017 .get("ref")?
1018 .as_str()
1019 .map(str::to_string)
1020}
1021
1022const UNHANDLED_VERDICT_LINT_KIND: &str = "verdict-value-unhandled";
1031
1032#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1034enum UnhandledVerdictGate {
1035 Deny,
1037 Warn,
1039 Silence,
1041}
1042
1043#[derive(Debug, Clone, PartialEq, Eq)]
1052struct UnhandledVerdictGates {
1053 per_agent: HashMap<String, UnhandledVerdictGate>,
1055 blueprint: UnhandledVerdictGate,
1057}
1058
1059impl UnhandledVerdictGates {
1060 fn for_agent(&self, agent: &str) -> UnhandledVerdictGate {
1063 self.per_agent.get(agent).copied().unwrap_or(self.blueprint)
1064 }
1065
1066 fn all_silent(&self) -> bool {
1069 self.blueprint == UnhandledVerdictGate::Silence
1070 && self
1071 .per_agent
1072 .values()
1073 .all(|g| *g == UnhandledVerdictGate::Silence)
1074 }
1075}
1076
1077fn resolve_unhandled_verdict_gates(bp: &Blueprint) -> UnhandledVerdictGates {
1085 let strict = bp.metadata.strict_verdict_handling.unwrap_or(false);
1086 let blueprint = resolve_unhandled_verdict_gate(&bp.metadata);
1087 let per_agent = bp
1088 .agents
1089 .iter()
1090 .filter_map(|ad| {
1091 let declared = declared_unhandled_verdict_setting(&ad.lints)?;
1092 Some((
1093 ad.name.clone(),
1094 unhandled_verdict_gate(strict, Some(declared)),
1095 ))
1096 })
1097 .collect();
1098 UnhandledVerdictGates {
1099 per_agent,
1100 blueprint,
1101 }
1102}
1103
1104fn resolve_unhandled_verdict_gate(metadata: &BlueprintMetadata) -> UnhandledVerdictGate {
1107 unhandled_verdict_gate(
1108 metadata.strict_verdict_handling.unwrap_or(false),
1109 declared_unhandled_verdict_setting(&metadata.lints),
1110 )
1111}
1112
1113fn declared_unhandled_verdict_setting(
1123 lints: &Option<BTreeMap<String, mlua_swarm_schema::LintSetting>>,
1124) -> Option<mlua_swarm_diag::LintSetting> {
1125 use mlua_swarm_diag::{lint_decl, LintConfig};
1126
1127 let cfg = LintConfig::from_pairs(
1128 lints
1129 .as_ref()?
1130 .iter()
1131 .map(|(key, setting)| (key.clone(), diag_lint_setting(*setting))),
1132 );
1133 cfg.setting_for(lint_decl(UNHANDLED_VERDICT_LINT_KIND)?)
1134}
1135
1136fn unhandled_verdict_gate(
1144 strict: bool,
1145 declared: Option<mlua_swarm_diag::LintSetting>,
1146) -> UnhandledVerdictGate {
1147 use mlua_swarm_diag::LintSetting;
1148
1149 match declared {
1150 _ if strict => UnhandledVerdictGate::Deny,
1151 Some(LintSetting::Deny) => UnhandledVerdictGate::Deny,
1152 Some(LintSetting::Allow) => UnhandledVerdictGate::Silence,
1153 Some(LintSetting::Warn) | None => UnhandledVerdictGate::Warn,
1154 }
1155}
1156
1157fn diag_lint_setting(setting: mlua_swarm_schema::LintSetting) -> mlua_swarm_diag::LintSetting {
1162 match setting {
1163 mlua_swarm_schema::LintSetting::Allow => mlua_swarm_diag::LintSetting::Allow,
1164 mlua_swarm_schema::LintSetting::Warn => mlua_swarm_diag::LintSetting::Warn,
1165 mlua_swarm_schema::LintSetting::Deny => mlua_swarm_diag::LintSetting::Deny,
1166 }
1167}
1168
1169fn verify_verdict_conds(
1179 flow: &FlowNode,
1180 verdict_contracts: &HashMap<String, VerdictContract>,
1181 unhandled_gates: &UnhandledVerdictGates,
1182) -> Result<(), CompileError> {
1183 let mut step_outputs: HashMap<String, String> = HashMap::new();
1184 let mut step_agents: HashMap<String, String> = HashMap::new();
1185 collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1186
1187 let mut errors: Vec<CompileError> = Vec::new();
1188 let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1189 collect_verdict_conds(
1190 flow,
1191 &step_outputs,
1192 verdict_contracts,
1193 &mut referenced_values,
1194 &mut errors,
1195 );
1196 check_unhandled_verdict_values(
1197 verdict_contracts,
1198 &referenced_values,
1199 &step_agents,
1200 unhandled_gates,
1201 &mut errors,
1202 );
1203 match errors.into_iter().next() {
1204 Some(e) => Err(e),
1205 None => Ok(()),
1206 }
1207}
1208
1209fn collect_step_outputs_and_agents(
1224 node: &FlowNode,
1225 out: &mut HashMap<String, String>,
1226 step_agents: &mut HashMap<String, String>,
1227) {
1228 match node {
1229 FlowNode::Step {
1230 ref_,
1231 out: out_expr,
1232 ..
1233 } => {
1234 if let Expr::Path { at } = out_expr {
1235 out.insert(at.to_string(), ref_.clone());
1236 }
1237 step_agents
1238 .entry(ref_.clone())
1239 .or_insert_with(|| ref_.clone());
1240 }
1241 FlowNode::Seq { children } => {
1242 for c in children {
1243 collect_step_outputs_and_agents(c, out, step_agents);
1244 }
1245 }
1246 FlowNode::Branch { then_, else_, .. } => {
1247 collect_step_outputs_and_agents(then_, out, step_agents);
1248 collect_step_outputs_and_agents(else_, out, step_agents);
1249 }
1250 FlowNode::Fanout { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1251 FlowNode::Loop { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1252 FlowNode::Try { body, catch, .. } => {
1253 collect_step_outputs_and_agents(body, out, step_agents);
1254 collect_step_outputs_and_agents(catch, out, step_agents);
1255 }
1256 FlowNode::Let { .. } => {} }
1258}
1259
1260fn collect_verdict_conds(
1265 node: &FlowNode,
1266 step_outputs: &HashMap<String, String>,
1267 verdict_contracts: &HashMap<String, VerdictContract>,
1268 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1269 errors: &mut Vec<CompileError>,
1270) {
1271 match node {
1272 FlowNode::Branch { cond, then_, else_ } => {
1273 lint_cond_expr(
1274 cond,
1275 "Branch cond",
1276 step_outputs,
1277 verdict_contracts,
1278 referenced_values,
1279 errors,
1280 );
1281 collect_verdict_conds(
1282 then_,
1283 step_outputs,
1284 verdict_contracts,
1285 referenced_values,
1286 errors,
1287 );
1288 collect_verdict_conds(
1289 else_,
1290 step_outputs,
1291 verdict_contracts,
1292 referenced_values,
1293 errors,
1294 );
1295 }
1296 FlowNode::Loop { cond, body, .. } => {
1297 lint_cond_expr(
1298 cond,
1299 "Loop cond",
1300 step_outputs,
1301 verdict_contracts,
1302 referenced_values,
1303 errors,
1304 );
1305 collect_verdict_conds(
1306 body,
1307 step_outputs,
1308 verdict_contracts,
1309 referenced_values,
1310 errors,
1311 );
1312 }
1313 FlowNode::Seq { children } => {
1314 for c in children {
1315 collect_verdict_conds(
1316 c,
1317 step_outputs,
1318 verdict_contracts,
1319 referenced_values,
1320 errors,
1321 );
1322 }
1323 }
1324 FlowNode::Fanout { body, .. } => collect_verdict_conds(
1325 body,
1326 step_outputs,
1327 verdict_contracts,
1328 referenced_values,
1329 errors,
1330 ),
1331 FlowNode::Try { body, catch, .. } => {
1332 collect_verdict_conds(
1333 body,
1334 step_outputs,
1335 verdict_contracts,
1336 referenced_values,
1337 errors,
1338 );
1339 collect_verdict_conds(
1340 catch,
1341 step_outputs,
1342 verdict_contracts,
1343 referenced_values,
1344 errors,
1345 );
1346 }
1347 FlowNode::Step { .. } | FlowNode::Let { .. } => {}
1348 }
1349}
1350
1351fn lint_cond_expr(
1360 expr: &Expr,
1361 where_: &str,
1362 step_outputs: &HashMap<String, String>,
1363 verdict_contracts: &HashMap<String, VerdictContract>,
1364 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1365 errors: &mut Vec<CompileError>,
1366) {
1367 match expr {
1368 Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
1369 if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
1370 resolve_and_check(
1371 path,
1372 &[lit],
1373 where_,
1374 step_outputs,
1375 verdict_contracts,
1376 referenced_values,
1377 errors,
1378 );
1379 }
1380 }
1381 Expr::In { needle, haystack } => {
1382 if let (
1383 Expr::Path { at },
1384 Expr::Lit {
1385 value: Value::Array(items),
1386 },
1387 ) = (needle.as_ref(), haystack.as_ref())
1388 {
1389 let lits: Vec<&Value> = items.iter().collect();
1390 resolve_and_check(
1391 at,
1392 &lits,
1393 where_,
1394 step_outputs,
1395 verdict_contracts,
1396 referenced_values,
1397 errors,
1398 );
1399 }
1400 }
1401 Expr::And { args } | Expr::Or { args } => {
1402 for a in args {
1403 lint_cond_expr(
1404 a,
1405 where_,
1406 step_outputs,
1407 verdict_contracts,
1408 referenced_values,
1409 errors,
1410 );
1411 }
1412 }
1413 Expr::Not { arg } => lint_cond_expr(
1414 arg,
1415 where_,
1416 step_outputs,
1417 verdict_contracts,
1418 referenced_values,
1419 errors,
1420 ),
1421 _ => {}
1422 }
1423}
1424
1425fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
1431 match (lhs, rhs) {
1432 (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
1433 (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
1434 _ => None,
1435 }
1436}
1437
1438fn resolve_and_check(
1453 path: &Path,
1454 lits: &[&Value],
1455 where_: &str,
1456 step_outputs: &HashMap<String, String>,
1457 verdict_contracts: &HashMap<String, VerdictContract>,
1458 referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1459 errors: &mut Vec<CompileError>,
1460) {
1461 let path_str = path.to_string();
1462 let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
1463 (agent, "body")
1464 } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
1465 match step_outputs.get(stripped) {
1466 Some(agent) => (agent, "part"),
1467 None => return,
1468 }
1469 } else {
1470 return;
1471 };
1472
1473 let Some(contract) = verdict_contracts.get(agent) else {
1474 tracing::warn!(
1475 agent = %agent,
1476 where_ = %where_,
1477 "cond references agent output but no verdict contract declared"
1478 );
1479 return;
1480 };
1481
1482 let expected_channel = match contract.channel {
1483 VerdictChannel::Body => "body",
1484 VerdictChannel::Part => "part",
1485 };
1486 if expected_channel != actual_shape {
1487 errors.push(CompileError::VerdictChannelMismatch {
1488 where_: where_.to_string(),
1489 agent: agent.clone(),
1490 expected_channel: expected_channel.to_string(),
1491 actual_shape: actual_shape.to_string(),
1492 });
1493 return;
1494 }
1495
1496 for lit in lits {
1497 let value_str = lit
1498 .as_str()
1499 .map(str::to_string)
1500 .unwrap_or_else(|| lit.to_string());
1501 if !contract.values.iter().any(|v| v == &value_str) {
1502 errors.push(CompileError::VerdictValueNotInContract {
1503 where_: where_.to_string(),
1504 agent: agent.clone(),
1505 value: value_str.clone(),
1506 values: contract.values.clone(),
1507 });
1508 }
1509 referenced_values
1516 .entry(agent.clone())
1517 .or_default()
1518 .insert(value_str);
1519 }
1520}
1521
1522fn check_unhandled_verdict_values(
1546 verdict_contracts: &HashMap<String, VerdictContract>,
1547 referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1548 step_agents: &HashMap<String, String>,
1549 unhandled_gates: &UnhandledVerdictGates,
1550 errors: &mut Vec<CompileError>,
1551) {
1552 if unhandled_gates.all_silent() {
1553 return;
1554 }
1555 for finding in fold_unhandled_verdict_values(verdict_contracts, referenced_values, step_agents)
1556 {
1557 let gate = unhandled_gates.for_agent(&finding.agent);
1558 match gate {
1559 UnhandledVerdictGate::Deny => errors.push(CompileError::VerdictValueUnhandled {
1560 agent: finding.agent,
1561 value: finding.value,
1562 declared_values: finding.declared_values,
1563 step_ref: finding.step_ref,
1564 }),
1565 UnhandledVerdictGate::Warn => tracing::warn!(
1566 agent = %finding.agent,
1567 value = %finding.value,
1568 step_ref = %finding.step_ref,
1569 "declared verdict value has no downstream cond handler; \
1570 declare `metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}` \
1571 to reject at compile"
1572 ),
1573 UnhandledVerdictGate::Silence => {}
1577 }
1578 }
1579}
1580
1581#[derive(Debug, Clone, PartialEq, Eq)]
1592pub struct UnhandledVerdictValue {
1593 pub agent: String,
1595 pub value: String,
1597 pub declared_values: Vec<String>,
1599 pub step_ref: String,
1601}
1602
1603pub fn unhandled_verdict_values(
1620 flow: &FlowNode,
1621 verdict_contracts: &HashMap<String, VerdictContract>,
1622) -> Vec<UnhandledVerdictValue> {
1623 let mut step_outputs: HashMap<String, String> = HashMap::new();
1624 let mut step_agents: HashMap<String, String> = HashMap::new();
1625 collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1626
1627 let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1628 let mut discarded_errors: Vec<CompileError> = Vec::new();
1629 collect_verdict_conds(
1630 flow,
1631 &step_outputs,
1632 verdict_contracts,
1633 &mut referenced_values,
1634 &mut discarded_errors,
1635 );
1636 fold_unhandled_verdict_values(verdict_contracts, &referenced_values, &step_agents)
1637}
1638
1639#[derive(Debug, Clone, PartialEq, Eq)]
1653pub struct AgentContractUnread {
1654 pub agent: String,
1656 pub declared_values: Vec<String>,
1658 pub step_ref: String,
1660}
1661
1662pub fn agents_with_all_verdict_values_unread(
1673 flow: &FlowNode,
1674 verdict_contracts: &HashMap<String, VerdictContract>,
1675) -> Vec<AgentContractUnread> {
1676 let per_value = unhandled_verdict_values(flow, verdict_contracts);
1677 let mut unread_counts: HashMap<String, usize> = HashMap::new();
1678 for finding in &per_value {
1679 *unread_counts.entry(finding.agent.clone()).or_default() += 1;
1680 }
1681 let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1682 agents.sort();
1683 let mut out = Vec::new();
1684 for agent in agents {
1685 let contract = &verdict_contracts[agent];
1686 let declared = contract.values.len();
1687 if declared == 0 {
1688 continue;
1689 }
1690 let unread = unread_counts.get(agent).copied().unwrap_or(0);
1691 if unread != declared {
1692 continue;
1693 }
1694 let step_ref = per_value
1698 .iter()
1699 .find(|f| &f.agent == agent)
1700 .map(|f| f.step_ref.clone())
1701 .unwrap_or_else(|| agent.clone());
1702 out.push(AgentContractUnread {
1703 agent: agent.clone(),
1704 declared_values: contract.values.clone(),
1705 step_ref,
1706 });
1707 }
1708 out
1709}
1710
1711fn fold_unhandled_verdict_values(
1722 verdict_contracts: &HashMap<String, VerdictContract>,
1723 referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1724 step_agents: &HashMap<String, String>,
1725) -> Vec<UnhandledVerdictValue> {
1726 let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1727 agents.sort();
1728 let mut findings = Vec::new();
1729 for agent in agents {
1730 let contract = &verdict_contracts[agent];
1731 let referenced = referenced_values.get(agent);
1732 let step_ref = step_agents
1733 .get(agent)
1734 .cloned()
1735 .unwrap_or_else(|| agent.clone());
1736 for value in &contract.values {
1737 let handled = referenced.map(|set| set.contains(value)).unwrap_or(false);
1738 if handled {
1739 continue;
1740 }
1741 findings.push(UnhandledVerdictValue {
1742 agent: agent.clone(),
1743 value: value.clone(),
1744 declared_values: contract.values.clone(),
1745 step_ref: step_ref.clone(),
1746 });
1747 }
1748 }
1749 findings
1750}
1751
1752pub struct CompiledAgentTable {
1765 pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
1766 pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
1767 pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
1771}
1772
1773impl CompiledAgentTable {
1774 pub fn has_route(&self, agent: &str) -> bool {
1777 self.routes.contains_key(agent)
1778 }
1779 pub fn routed_agents(&self) -> Vec<String> {
1781 self.routes.keys().cloned().collect()
1782 }
1783 pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
1787 self.verdict_contracts.get(agent)
1788 }
1789}
1790
1791#[async_trait]
1792impl SpawnerAdapter for CompiledAgentTable {
1793 async fn spawn(
1794 &self,
1795 engine: &Engine,
1796 ctx: &Ctx,
1797 task_id: StepId,
1798 attempt: u32,
1799 token: CapToken,
1800 ) -> Result<Box<dyn Worker>, SpawnError> {
1801 let sp = self
1802 .routes
1803 .get(&ctx.agent)
1804 .cloned()
1805 .or_else(|| self.default.clone())
1806 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
1807 sp.spawn(engine, ctx, task_id, attempt, token).await
1808 }
1809}
1810
1811pub struct SubprocessProcessSpawnerFactory;
1842
1843impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
1844 const KIND: AgentKind = AgentKind::Subprocess;
1845 type Worker = crate::worker::process_spawner::ProcessWorker;
1846}
1847
1848pub const SUBPROCESS_TEMPLATE_HINT_KEY: &str = "subprocess_template";
1851pub const SUBPROCESS_OVERRIDES_HINT_KEY: &str = "subprocess_overrides";
1853
1854fn validate_embed_placeholders(s: &str, where_: &str) -> Result<(), String> {
1868 let mut rest = s;
1869 while let Some(start) = rest.find('{') {
1870 let after = &rest[start + 1..];
1871 let Some(end) = after.find('}') else {
1872 break;
1873 };
1874 let token = &after[..end];
1875 let is_candidate =
1876 !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase() || c == '_');
1877 if is_candidate {
1878 if !crate::worker::process_spawner::EMBED_PLACEHOLDERS.contains(&token) {
1879 return Err(format!(
1880 "unknown placeholder '{{{token}}}' in {where_}; closed set is \
1881 {{system, system_file, prompt, model, tools_csv, work_dir, task_id, attempt}}"
1882 ));
1883 }
1884 rest = &after[end + 1..];
1885 } else {
1886 rest = after;
1891 }
1892 }
1893 Ok(())
1894}
1895
1896fn resolve_subprocess_template_hint(
1902 bp: &Blueprint,
1903 ad: &AgentDef,
1904) -> Result<Option<Value>, CompileError> {
1905 let invalid = |msg: String| CompileError::InvalidSpec {
1906 name: ad.name.clone(),
1907 msg,
1908 };
1909 let runner = mlua_swarm_schema::resolve_runner(bp, ad).map_err(|e| invalid(e.to_string()))?;
1910 let Some(Runner::Subprocess {
1911 template,
1912 overrides,
1913 }) = runner
1914 else {
1915 return Ok(None);
1916 };
1917 let def = bp
1918 .subprocesses
1919 .iter()
1920 .find(|d| d.name == template)
1921 .ok_or_else(|| {
1922 let mut names: Vec<&str> = bp.subprocesses.iter().map(|d| d.name.as_str()).collect();
1923 names.sort_unstable();
1924 invalid(format!(
1925 "Runner::Subprocess template '{template}' not found in \
1926 Blueprint.subprocesses (defined: [{}])",
1927 names.join(", ")
1928 ))
1929 })?;
1930 Ok(Some(serde_json::json!({
1931 SUBPROCESS_TEMPLATE_HINT_KEY: def,
1932 SUBPROCESS_OVERRIDES_HINT_KEY: overrides,
1933 })))
1934}
1935
1936impl SubprocessProcessSpawnerFactory {
1937 fn build_embed(
1942 agent_def: &AgentDef,
1943 template: &Value,
1944 overrides: Option<&Value>,
1945 ) -> Result<ProcessSpawner, CompileError> {
1946 use crate::worker::process_spawner::EmbedTemplate;
1947 use mlua_swarm_schema::{SubprocessDef, SubprocessOverrides};
1948
1949 let agent_name = &agent_def.name;
1950 let invalid = |msg: String| CompileError::InvalidSpec {
1951 name: agent_name.to_string(),
1952 msg,
1953 };
1954 let def: SubprocessDef = serde_json::from_value(template.clone())
1955 .map_err(|e| invalid(format!("subprocess_template hint: {e}")))?;
1956 let overrides: SubprocessOverrides = match overrides {
1957 Some(v) => serde_json::from_value(v.clone())
1958 .map_err(|e| invalid(format!("subprocess_overrides hint: {e}")))?,
1959 None => SubprocessOverrides::default(),
1960 };
1961
1962 if def.argv.is_empty() {
1963 return Err(invalid(format!(
1964 "SubprocessDef '{}': argv must not be empty",
1965 def.name
1966 )));
1967 }
1968 for (i, a) in def.argv.iter().enumerate() {
1970 validate_embed_placeholders(a, &format!("argv[{i}]")).map_err(&invalid)?;
1971 }
1972 if let Some(stdin) = &def.stdin {
1973 validate_embed_placeholders(stdin, "stdin").map_err(&invalid)?;
1974 }
1975 for (k, v) in &def.env {
1976 validate_embed_placeholders(v, &format!("env['{k}']")).map_err(&invalid)?;
1977 }
1978 if let Some(cwd) = &def.cwd {
1979 validate_embed_placeholders(cwd, "cwd").map_err(&invalid)?;
1980 }
1981 let stream_mode = match def.stream_mode.as_deref() {
1982 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
1983 Some("sse_events") => Some(StreamMode::SseEvents),
1984 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
1985 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
1986 None => None,
1987 };
1988 if def.timeout_ms == Some(0) {
1989 return Err(invalid(format!(
1990 "SubprocessDef '{}': timeout_ms must be greater than 0 \
1991 (omit the field for no per-attempt limit)",
1992 def.name
1993 )));
1994 }
1995 if let Some(output) = &def.output {
1996 if stream_mode.is_some() {
1997 return Err(invalid(format!(
1998 "SubprocessDef '{}': output normalization is a plain-mode \
1999 declaration; remove either `output` or `stream_mode`",
2000 def.name
2001 )));
2002 }
2003 if let Some(format) = output.format.as_deref() {
2004 if format != "json" {
2005 return Err(invalid(format!(
2006 "SubprocessDef '{}': unknown output.format '{format}' \
2007 (supported: \"json\")",
2008 def.name
2009 )));
2010 }
2011 }
2012 if let Some(ptr) = output.result_ptr.as_deref() {
2013 if !ptr.starts_with('/') {
2014 return Err(invalid(format!(
2015 "SubprocessDef '{}': output.result_ptr '{ptr}' is not a \
2016 JSON Pointer (RFC 6901 — must start with '/')",
2017 def.name
2018 )));
2019 }
2020 }
2021 if let Some(ok_from) = output.ok_from.as_deref() {
2022 if ok_from != "exit_code" && !ok_from.starts_with('/') {
2023 return Err(invalid(format!(
2024 "SubprocessDef '{}': output.ok_from '{ok_from}' must be \
2025 \"exit_code\" or a JSON Pointer (starting with '/')",
2026 def.name
2027 )));
2028 }
2029 }
2030 }
2031
2032 let profile = agent_def.profile.as_ref();
2035 let system_prompt = profile
2036 .map(|p| p.system_prompt.clone())
2037 .filter(|s| !s.is_empty());
2038 let model = overrides
2039 .model
2040 .clone()
2041 .or_else(|| profile.and_then(|p| p.model.clone()));
2042 let tools: Vec<String> = if overrides.tools.is_empty() {
2043 profile.map(|p| p.tools.clone()).unwrap_or_default()
2044 } else {
2045 overrides.tools.clone()
2046 };
2047 let cwd = overrides.cwd.clone().or_else(|| def.cwd.clone());
2049 if let Some(c) = &cwd {
2050 validate_embed_placeholders(c, "overrides.cwd").map_err(&invalid)?;
2051 }
2052
2053 let program = def.argv[0].clone();
2054 let sp = ProcessSpawner {
2055 program,
2056 args: Vec::new(),
2057 use_stdin: def.stdin.is_some(),
2058 stream_mode,
2059 embed: Some(EmbedTemplate {
2060 argv: def.argv,
2061 stdin: def.stdin,
2062 env: def.env,
2063 cwd,
2064 output: def.output,
2065 system_prompt,
2066 model,
2067 tools_csv: tools.join(","),
2068 timeout: def.timeout_ms.map(std::time::Duration::from_millis),
2069 }),
2070 };
2071 Ok(sp)
2072 }
2073}
2074
2075impl SpawnerFactory for SubprocessProcessSpawnerFactory {
2076 fn build(
2077 &self,
2078 agent_def: &AgentDef,
2079 hint: Option<&Value>,
2080 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2081 if let Some(template) = hint.and_then(|h| h.get(SUBPROCESS_TEMPLATE_HINT_KEY)) {
2085 let overrides = hint.and_then(|h| h.get(SUBPROCESS_OVERRIDES_HINT_KEY));
2086 return Self::build_embed(agent_def, template, overrides).map(|sp| {
2087 let arc: Arc<dyn SpawnerAdapter> = Arc::new(sp);
2088 arc
2089 });
2090 }
2091 let agent_name = &agent_def.name;
2092 let spec = &agent_def.spec;
2093 let invalid = |msg: String| CompileError::InvalidSpec {
2094 name: agent_name.to_string(),
2095 msg,
2096 };
2097 let program = spec
2098 .get("program")
2099 .and_then(|v| v.as_str())
2100 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
2101 .to_string();
2102 let args: Vec<String> = spec
2103 .get("args")
2104 .and_then(|v| v.as_array())
2105 .map(|a| {
2106 a.iter()
2107 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2108 .collect()
2109 })
2110 .unwrap_or_default();
2111 let use_stdin = spec
2112 .get("use_stdin")
2113 .and_then(|v| v.as_bool())
2114 .unwrap_or(true);
2115 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
2116 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
2117 Some("sse_events") => Some(StreamMode::SseEvents),
2118 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
2119 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
2120 None => None,
2121 };
2122
2123 let mut sp = ProcessSpawner {
2124 program,
2125 args,
2126 use_stdin,
2127 stream_mode,
2128 embed: None,
2129 };
2130 if let Some(mode) = sp.stream_mode.clone() {
2131 sp = sp.stream_mode(mode);
2132 }
2133 Ok(Arc::new(sp))
2134 }
2135}
2136
2137pub struct LuaInProcessSpawnerFactory {
2168 registry: HashMap<String, WorkerFn>,
2169 bridges: HashMap<String, HostBridge>,
2170}
2171
2172#[derive(Clone)]
2184pub struct HostBridge(
2185 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
2186);
2187
2188impl HostBridge {
2189 pub fn new<F>(f: F) -> Self
2191 where
2192 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
2193 {
2194 Self(Arc::new(f))
2195 }
2196
2197 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
2201 (self.0)(arg)
2202 }
2203}
2204
2205#[derive(Clone)]
2212pub struct LuaScriptSource {
2213 pub source: String,
2215 pub label: String,
2218}
2219
2220impl LuaScriptSource {
2221 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
2223 Self {
2224 source: source.into(),
2225 label: label.into(),
2226 }
2227 }
2228}
2229
2230impl LuaInProcessSpawnerFactory {
2231 pub fn new() -> Self {
2233 Self {
2234 registry: HashMap::new(),
2235 bridges: HashMap::new(),
2236 }
2237 }
2238
2239 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
2246 self.bridges.insert(name.into(), bridge);
2247 self
2248 }
2249
2250 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
2268 let source = Arc::new(source);
2269 let bridges = Arc::new(self.bridges.clone());
2270 let wrapped: WorkerFn = Arc::new(move |inv| {
2271 let source = source.clone();
2272 let bridges = bridges.clone();
2273 Box::pin(run_lua_worker(source, bridges, inv))
2274 });
2275 self.registry.insert(fn_id.into(), wrapped);
2276 self
2277 }
2278}
2279
2280async fn run_lua_worker(
2282 source: Arc<LuaScriptSource>,
2283 bridges: Arc<HashMap<String, HostBridge>>,
2284 inv: crate::worker::adapter::WorkerInvocation,
2285) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
2286 use crate::worker::adapter::WorkerError;
2287 use mlua::LuaSerdeExt;
2288
2289 let label = source.label.clone();
2290 let outcome =
2291 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
2292 let lua = mlua::Lua::new();
2293 let g = lua.globals();
2294
2295 g.set("_PROMPT", inv.prompt.clone())
2297 .map_err(|e| format!("set _PROMPT: {e}"))?;
2298 g.set("_AGENT", inv.agent.clone())
2299 .map_err(|e| format!("set _AGENT: {e}"))?;
2300 g.set("_TASK_ID", inv.task_id.to_string())
2301 .map_err(|e| format!("set _TASK_ID: {e}"))?;
2302 g.set("_ATTEMPT", inv.attempt as i64)
2303 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
2304
2305 for (name, value) in
2314 crate::worker::agent_block::runtime::context_globals(inv.context.as_ref())
2315 {
2316 let lua_val = lua
2317 .to_value(&value)
2318 .map_err(|e| format!("{name} to_value: {e}"))?;
2319 g.set(name.as_str(), lua_val)
2320 .map_err(|e| format!("set {name}: {e}"))?;
2321 }
2322
2323 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
2325 let lua_val = lua
2326 .to_value(&json_val)
2327 .map_err(|e| format!("_CTX to_value: {e}"))?;
2328 g.set("_CTX", lua_val)
2329 .map_err(|e| format!("set _CTX: {e}"))?;
2330 }
2331
2332 if !bridges.is_empty() {
2334 let host = lua
2335 .create_table()
2336 .map_err(|e| format!("create host table: {e}"))?;
2337 for (name, bridge) in bridges.iter() {
2338 let bridge = bridge.clone();
2339 let bname = name.clone();
2340 let f = lua
2341 .create_function(move |lua, arg: mlua::Value| {
2342 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
2343 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
2344 })?;
2345 let result_json =
2346 bridge.call(json_arg).map_err(mlua::Error::external)?;
2347 lua.to_value(&result_json).map_err(|e| {
2348 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
2349 })
2350 })
2351 .map_err(|e| format!("create_function {name}: {e}"))?;
2352 host.set(name.as_str(), f)
2353 .map_err(|e| format!("host.{name} set: {e}"))?;
2354 }
2355 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
2356 }
2357
2358 let result: mlua::Value = lua
2360 .load(&source.source)
2361 .set_name(&source.label)
2362 .eval()
2363 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
2364
2365 let json_result: serde_json::Value = lua
2367 .from_value(result)
2368 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
2369
2370 let (value, ok) = match &json_result {
2371 serde_json::Value::Object(map)
2372 if map.contains_key("value") || map.contains_key("ok") =>
2373 {
2374 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
2375 let value = map.get("value").cloned().unwrap_or(json_result.clone());
2376 (value, ok)
2377 }
2378 _ => (json_result, true),
2379 };
2380 Ok((value, ok))
2381 })
2382 .await
2383 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
2384 .map_err(WorkerError::Failed)?;
2385
2386 Ok(crate::worker::adapter::WorkerResult {
2387 value: outcome.0,
2388 ok: outcome.1,
2389 stats: None,
2390 }
2391 .ensure_worker_kind("lua"))
2392}
2393
2394impl Default for LuaInProcessSpawnerFactory {
2395 fn default() -> Self {
2396 Self::new()
2397 }
2398}
2399
2400impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
2401 const KIND: AgentKind = AgentKind::Lua;
2402 type Worker = LuaWorker;
2403}
2404
2405impl SpawnerFactory for LuaInProcessSpawnerFactory {
2406 fn build(
2407 &self,
2408 agent_def: &AgentDef,
2409 _hint: Option<&Value>,
2410 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2411 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
2417 let label = agent_def
2418 .spec
2419 .get("label")
2420 .and_then(|v| v.as_str())
2421 .map(str::to_string)
2422 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
2423 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
2424 let bridges = Arc::new(self.bridges.clone());
2425 let wrapped: WorkerFn = Arc::new(move |inv| {
2426 let source = script.clone();
2427 let bridges = bridges.clone();
2428 Box::pin(run_lua_worker(source, bridges, inv))
2429 });
2430 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
2431 sp.registry.insert(agent_def.name.to_string(), wrapped);
2432 return Ok(Arc::new(sp));
2433 }
2434 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
2435 }
2436}
2437
2438pub struct RustFnInProcessSpawnerFactory {
2452 registry: HashMap<String, WorkerFn>,
2453}
2454
2455impl RustFnInProcessSpawnerFactory {
2456 pub fn new() -> Self {
2458 Self {
2459 registry: HashMap::new(),
2460 }
2461 }
2462
2463 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
2466 where
2467 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
2468 Fut: std::future::Future<
2469 Output = Result<
2470 crate::worker::adapter::WorkerResult,
2471 crate::worker::adapter::WorkerError,
2472 >,
2473 > + Send
2474 + 'static,
2475 {
2476 let f = Arc::new(f);
2477 let wrapped: WorkerFn = Arc::new(move |inv| {
2478 let f = f.clone();
2479 Box::pin(f(inv))
2480 });
2481 self.registry.insert(fn_id.into(), wrapped);
2482 self
2483 }
2484}
2485
2486impl Default for RustFnInProcessSpawnerFactory {
2487 fn default() -> Self {
2488 Self::new()
2489 }
2490}
2491
2492impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
2493 const KIND: AgentKind = AgentKind::RustFn;
2494 type Worker = RustFnWorker;
2495}
2496
2497impl SpawnerFactory for RustFnInProcessSpawnerFactory {
2498 fn build(
2499 &self,
2500 agent_def: &AgentDef,
2501 _hint: Option<&Value>,
2502 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2503 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
2504 }
2505}
2506
2507fn build_inproc_from_registry<W>(
2513 registry: &HashMap<String, WorkerFn>,
2514 agent_def: &AgentDef,
2515 kind_label: &str,
2516) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
2517where
2518 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
2519{
2520 let agent_name = &agent_def.name;
2521 let spec = &agent_def.spec;
2522 let invalid = |msg: String| CompileError::InvalidSpec {
2523 name: agent_name.to_string(),
2524 msg,
2525 };
2526 let fn_id = spec
2527 .get("fn_id")
2528 .and_then(|v| v.as_str())
2529 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
2530 let f = registry
2531 .get(fn_id)
2532 .cloned()
2533 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
2534 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
2535 sp.registry.insert(agent_name.to_string(), f);
2539 Ok(Arc::new(sp))
2540}
2541
2542pub struct LuaWorker {
2547 pub handler: crate::worker::WorkerJoinHandler,
2549}
2550
2551impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
2552 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2553 Self { handler }
2554 }
2555}
2556
2557#[async_trait::async_trait]
2558impl crate::worker::Worker for LuaWorker {
2559 fn id(&self) -> &crate::types::WorkerId {
2560 &self.handler.worker_id
2561 }
2562 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2563 self.handler.cancel.clone()
2564 }
2565 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2566 self.handler.await_completion().await
2567 }
2568}
2569
2570pub struct RustFnWorker {
2575 pub handler: crate::worker::WorkerJoinHandler,
2577}
2578
2579impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
2580 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2581 Self { handler }
2582 }
2583}
2584
2585#[async_trait::async_trait]
2586impl crate::worker::Worker for RustFnWorker {
2587 fn id(&self) -> &crate::types::WorkerId {
2588 &self.handler.worker_id
2589 }
2590 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2591 self.handler.cancel.clone()
2592 }
2593 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2594 self.handler.await_completion().await
2595 }
2596}
2597
2598pub struct OperatorSpawnerFactory {
2666 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
2667 slot_resolver: Arc<std::sync::RwLock<Option<Arc<dyn OperatorSlotResolver>>>>,
2670}
2671
2672impl OperatorSpawnerFactory {
2673 pub fn new() -> Self {
2675 Self {
2676 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
2677 slot_resolver: Arc::new(std::sync::RwLock::new(None)),
2678 }
2679 }
2680
2681 pub fn set_slot_resolver(&self, resolver: Arc<dyn OperatorSlotResolver>) -> &Self {
2690 *self
2691 .slot_resolver
2692 .write()
2693 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned") = Some(resolver);
2694 self
2695 }
2696
2697 pub fn resolve_operator(
2705 &self,
2706 slot: &str,
2707 agent: &str,
2708 ) -> Result<Arc<dyn Operator>, CompileError> {
2709 let invalid = |msg: String| CompileError::InvalidSpec {
2710 name: agent.to_string(),
2711 msg,
2712 };
2713 let resolver = self
2714 .slot_resolver
2715 .read()
2716 .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned")
2717 .clone();
2718 if let Some(resolver) = resolver {
2719 return resolver.resolve(slot).ok_or_else(|| {
2720 invalid(format!(
2721 "operator_ref '{slot}': the installed OperatorSlotResolver serves no such \
2722 Operator seat. The seat is declared by Blueprint.operators[]; nothing is \
2723 resolved from the factory's own registry here, because falling back to it \
2724 would dispatch this agent to a backend the seat does not name."
2725 ))
2726 });
2727 }
2728 let operators = self
2729 .operators
2730 .read()
2731 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
2732 operators.get(slot).cloned().ok_or_else(|| {
2733 let mut names: Vec<String> = operators.keys().cloned().collect();
2734 names.sort();
2735 let names_list = if names.is_empty() {
2736 "<none>".to_string()
2737 } else {
2738 names.join(", ")
2739 };
2740 invalid(format!(
2741 "operator_ref '{slot}' not registered in factory. \
2742 Registered sids: [{names_list}]. \
2743 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
2744 ))
2745 })
2746 }
2747
2748 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
2754 self.operators
2755 .write()
2756 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2757 .insert(id.into(), op);
2758 self
2759 }
2760
2761 pub fn unregister_operator(&self, id: &str) -> &Self {
2764 self.operators
2765 .write()
2766 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2767 .remove(id);
2768 self
2769 }
2770}
2771
2772impl Default for OperatorSpawnerFactory {
2773 fn default() -> Self {
2774 Self::new()
2775 }
2776}
2777
2778impl SpawnerFactoryKind for OperatorSpawnerFactory {
2779 const KIND: AgentKind = AgentKind::Operator;
2780 type Worker = crate::operator::OperatorWorker;
2781}
2782
2783impl SpawnerFactory for OperatorSpawnerFactory {
2784 fn build(
2789 &self,
2790 agent_def: &AgentDef,
2791 _hint: Option<&Value>,
2792 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2793 let agent_name = &agent_def.name;
2794 let spec = &agent_def.spec;
2795 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
2801 let invalid = |msg: String| CompileError::InvalidSpec {
2802 name: agent_name.to_string(),
2803 msg,
2804 };
2805 let op_ref = spec
2806 .get("operator_ref")
2807 .and_then(|v| v.as_str())
2808 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
2809 let op = self.resolve_operator(op_ref, agent_name)?;
2813
2814 let worker_binding = agent_def
2821 .profile
2822 .as_ref()
2823 .and_then(|p| p.worker_binding.as_ref())
2824 .map(|variant| WorkerBinding {
2825 variant: variant.clone(),
2826 tools: agent_def
2827 .profile
2828 .as_ref()
2829 .map(|p| p.tools.clone())
2830 .unwrap_or_default(),
2831 request_digest: None,
2835 requested_model: None,
2836 });
2837 if op.requires_worker_binding() && worker_binding.is_none() {
2838 return Err(invalid(format!(
2845 "{WORKER_BINDING_REQUIRED_MSG_PREFIX}. \
2846 Fix by either: \
2847 (a) if authoring the Blueprint JSON directly, add \
2848 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
2849 to the JSON literal; or \
2850 (b) if using an $agent_md file ref, add \
2851 `worker_binding: <subagent-type>` to the agent .md frontmatter."
2852 )));
2853 }
2854 Ok(Arc::new(OperatorSpawner::new(
2855 op,
2856 system_prompt,
2857 worker_binding,
2858 )))
2859 }
2860}
2861
2862#[cfg(test)]
2863mod operator_spawner_factory_worker_binding_tests {
2864 use super::*;
2865 use crate::blueprint::AgentProfile;
2866 use crate::core::ctx::Ctx;
2867 use crate::types::CapToken;
2868 use crate::worker::adapter::{WorkerError, WorkerResult};
2869
2870 struct StubOperator {
2875 requires_binding: bool,
2876 }
2877
2878 #[async_trait]
2879 impl Operator for StubOperator {
2880 async fn execute(
2881 &self,
2882 _ctx: &Ctx,
2883 _system: Option<String>,
2884 _prompt: Value,
2885 _worker: Option<WorkerBinding>,
2886 _worker_token: CapToken,
2887 ) -> Result<WorkerResult, WorkerError> {
2888 Ok(WorkerResult {
2889 value: Value::Null,
2890 ok: true,
2891 stats: None,
2892 })
2893 }
2894
2895 fn requires_worker_binding(&self) -> bool {
2896 self.requires_binding
2897 }
2898 }
2899
2900 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
2901 AgentDef {
2902 name: "test-agent".to_string(),
2903 kind: AgentKind::Operator,
2904 spec: serde_json::json!({ "operator_ref": "op1" }),
2905 profile,
2906 meta: None,
2907 runner: None,
2908 runner_ref: None,
2909 verdict: None,
2910 lints: None,
2911 max_concurrency: None,
2912 }
2913 }
2914
2915 #[test]
2916 fn build_fails_loud_when_binding_required_but_absent() {
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 match factory.build(&def, None) {
2926 Err(CompileError::InvalidSpec { name, msg }) => {
2927 assert_eq!(name, "test-agent");
2928 assert!(
2929 msg.contains("worker_binding is required"),
2930 "unexpected message: {msg}"
2931 );
2932 assert!(
2936 msg.contains("agents[N].profile.worker_binding"),
2937 "message missing JSON-direct hint (issue #9): {msg}"
2938 );
2939 assert!(
2940 msg.contains("agent .md frontmatter"),
2941 "message missing $agent_md hint: {msg}"
2942 );
2943 }
2944 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
2945 Ok(_) => panic!("expected compile-time failure, got Ok"),
2946 }
2947 }
2948
2949 #[test]
2956 fn factory_error_message_carries_the_shared_prefix_and_specializes_the_diagnostic() {
2957 let factory = OperatorSpawnerFactory::new();
2958 factory.register_operator(
2959 "op1",
2960 Arc::new(StubOperator {
2961 requires_binding: true,
2962 }) as Arc<dyn Operator>,
2963 );
2964 let def = agent_def_with(Some(AgentProfile::default()));
2965 let err = match factory.build(&def, None) {
2966 Err(err) => err,
2967 Ok(_) => panic!("expected compile-time failure, got Ok"),
2968 };
2969 match &err {
2970 CompileError::InvalidSpec { msg, .. } => {
2971 assert!(
2972 msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX),
2973 "factory message must start with the shared prefix, got: {msg}"
2974 );
2975 }
2976 other => panic!("expected InvalidSpec, got: {other:?}"),
2977 }
2978 let d = mlua_swarm_diag::Diagnostic::from(&err);
2979 assert_eq!(d.kind, "worker-binding-missing");
2980 }
2981
2982 #[test]
2983 fn build_succeeds_when_binding_required_and_present() {
2984 let factory = OperatorSpawnerFactory::new();
2985 factory.register_operator(
2986 "op1",
2987 Arc::new(StubOperator {
2988 requires_binding: true,
2989 }) as Arc<dyn Operator>,
2990 );
2991 let profile = AgentProfile {
2992 worker_binding: Some("code-worker".to_string()),
2993 tools: vec!["Read".to_string(), "Edit".to_string()],
2994 ..Default::default()
2995 };
2996 let def = agent_def_with(Some(profile));
2997 assert!(
2998 factory.build(&def, None).is_ok(),
2999 "expected Ok when worker_binding is declared"
3000 );
3001 }
3002
3003 #[test]
3004 fn build_succeeds_when_binding_not_required_and_absent() {
3005 let factory = OperatorSpawnerFactory::new();
3006 factory.register_operator(
3007 "op1",
3008 Arc::new(StubOperator {
3009 requires_binding: false,
3010 }) as Arc<dyn Operator>,
3011 );
3012 let def = agent_def_with(Some(AgentProfile::default()));
3013 assert!(
3014 factory.build(&def, None).is_ok(),
3015 "backends that don't require a binding must not be gated by its absence"
3016 );
3017 }
3018}
3019
3020#[cfg(test)]
3028mod lua_inline_source_tests {
3029 use super::*;
3030 use crate::types::{CapToken, Role, StepId};
3031
3032 fn agent(name: &str, spec: Value) -> AgentDef {
3033 AgentDef {
3034 name: name.to_string(),
3035 kind: AgentKind::Lua,
3036 spec,
3037 profile: None,
3038 meta: None,
3039 runner: None,
3040 runner_ref: None,
3041 verdict: None,
3042 lints: None,
3043 max_concurrency: None,
3044 }
3045 }
3046
3047 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
3048 crate::worker::adapter::WorkerInvocation::new(
3049 CapToken {
3050 agent_id: "a".into(),
3051 role: Role::Worker,
3052 scopes: vec!["*".into()],
3053 issued_at: 0,
3054 expire_at: u64::MAX / 2,
3055 max_uses: None,
3056 nonce: "test-nonce".into(),
3057 sig_hex: "".into(),
3058 },
3059 StepId::parse("ST-test").expect("StepId parse"),
3060 1,
3061 "g",
3062 prompt,
3063 )
3064 }
3065
3066 #[test]
3067 fn build_accepts_inline_source_without_pre_registration() {
3068 let factory = LuaInProcessSpawnerFactory::new();
3069 let def = agent(
3070 "g",
3071 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
3072 );
3073 assert!(
3074 factory.build(&def, None).is_ok(),
3075 "inline spec.source must build without a pre-registered fn_id"
3076 );
3077 }
3078
3079 #[test]
3080 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
3081 let factory = LuaInProcessSpawnerFactory::new();
3082 let def = agent("g", serde_json::json!({}));
3083 match factory.build(&def, None) {
3084 Err(CompileError::InvalidSpec { msg, .. }) => {
3085 assert!(
3086 msg.contains("fn_id"),
3087 "empty spec must still surface the fn_id-required message: {msg}"
3088 );
3089 }
3090 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3091 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
3094 }
3095 }
3096
3097 #[tokio::test]
3101 async fn inline_source_evaluates_and_marshals_result() {
3102 let source =
3103 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
3104 let out = run_lua_worker(
3105 std::sync::Arc::new(source),
3106 std::sync::Arc::new(HashMap::new()),
3107 test_invocation("hello"),
3108 )
3109 .await
3110 .expect("lua worker ok");
3111 assert_eq!(out.value, serde_json::json!("hello!"));
3112 assert!(out.ok);
3113 }
3114
3115 #[tokio::test]
3116 async fn inline_source_can_signal_agent_level_failure() {
3117 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
3120 let out = run_lua_worker(
3121 std::sync::Arc::new(source),
3122 std::sync::Arc::new(HashMap::new()),
3123 test_invocation("input"),
3124 )
3125 .await
3126 .expect("lua worker ok");
3127 assert_eq!(out.value, serde_json::json!("nope"));
3128 assert!(!out.ok);
3129 }
3130}
3131
3132#[cfg(test)]
3135mod meta_ref_validation_tests {
3136 use super::*;
3137 use crate::blueprint::{AgentMeta, MetaDef};
3138 use crate::worker::adapter::WorkerResult;
3139
3140 fn registry_with_echo() -> SpawnerRegistry {
3141 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3142 Ok(WorkerResult {
3143 value: Value::String(inv.prompt),
3144 ok: true,
3145 stats: None,
3146 })
3147 });
3148 let mut reg = SpawnerRegistry::new();
3149 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3150 reg
3151 }
3152
3153 fn rustfn_agent(name: &str) -> AgentDef {
3154 AgentDef {
3155 name: name.to_string(),
3156 kind: AgentKind::RustFn,
3157 spec: serde_json::json!({ "fn_id": "echo" }),
3158 profile: None,
3159 meta: None,
3160 runner: None,
3161 runner_ref: None,
3162 verdict: None,
3163 lints: None,
3164 max_concurrency: None,
3165 }
3166 }
3167
3168 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
3169 FlowNode::Step {
3170 ref_: agent_ref.to_string(),
3171 in_,
3172 out: Expr::Path {
3173 at: "$.output".parse().expect("literal test path: $.output"),
3174 },
3175 }
3176 }
3177
3178 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
3179 Blueprint {
3180 schema_version: crate::blueprint::current_schema_version(),
3181 id: "meta-ref-ut".into(),
3182 flow,
3183 agents,
3184 operators: vec![],
3185 metas,
3186 hints: Default::default(),
3187 strategy: Default::default(),
3188 metadata: BlueprintMetadata::default(),
3189 spawner_hints: Default::default(),
3190 default_agent_kind: AgentKind::Operator,
3191 default_operator_kind: None,
3192 default_init_ctx: None,
3193 default_agent_ctx: None,
3194 default_context_policy: None,
3195 projection_placement: None,
3196 audits: vec![],
3197 degradation_policy: None,
3198 runners: vec![],
3199 default_runner: None,
3200 subprocesses: vec![],
3201 check_policy: None,
3202 blueprint_ref_includes: Vec::new(),
3203 }
3204 }
3205
3206 #[test]
3207 fn valid_meta_ref_compiles() {
3208 let mut agent = rustfn_agent("worker");
3209 agent.meta = Some(AgentMeta {
3210 meta_ref: Some("shared".to_string()),
3211 ..Default::default()
3212 });
3213 let bp = minimal_bp(
3214 vec![agent],
3215 vec![MetaDef {
3216 name: "shared".into(),
3217 ctx: serde_json::json!({ "k": "v" }),
3218 }],
3219 simple_flow(
3220 "worker",
3221 Expr::Path {
3222 at: "$.input".parse().expect("literal test path: $.input"),
3223 },
3224 ),
3225 );
3226 let compiler = Compiler::new(registry_with_echo());
3227 assert!(
3228 compiler.compile(&bp).is_ok(),
3229 "a resolvable AgentMeta.meta_ref must compile"
3230 );
3231 }
3232
3233 #[test]
3234 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
3235 let mut agent = rustfn_agent("worker");
3236 agent.meta = Some(AgentMeta {
3237 meta_ref: Some("missing".to_string()),
3238 ..Default::default()
3239 });
3240 let bp = minimal_bp(
3241 vec![agent],
3242 vec![],
3243 simple_flow(
3244 "worker",
3245 Expr::Path {
3246 at: "$.input".parse().expect("literal test path: $.input"),
3247 },
3248 ),
3249 );
3250 let compiler = Compiler::new(registry_with_echo());
3251 match compiler.compile(&bp) {
3252 Err(CompileError::UnresolvedMetaRef {
3253 where_,
3254 meta_ref,
3255 defined,
3256 }) => {
3257 assert!(
3258 where_.contains("worker"),
3259 "where_ must name the agent: {where_}"
3260 );
3261 assert_eq!(meta_ref, "missing");
3262 assert!(defined.is_empty());
3263 }
3264 Err(other) => {
3265 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3266 }
3267 Ok(_) => panic!("expected compile-time failure, got Ok"),
3268 }
3269 }
3270
3271 #[test]
3272 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
3273 let agent = rustfn_agent("worker");
3274 let in_ = Expr::Lit {
3275 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
3276 };
3277 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
3278 let compiler = Compiler::new(registry_with_echo());
3279 match compiler.compile(&bp) {
3280 Err(CompileError::UnresolvedMetaRef {
3281 where_, meta_ref, ..
3282 }) => {
3283 assert!(
3284 where_.contains("worker"),
3285 "where_ must name the offending step: {where_}"
3286 );
3287 assert_eq!(meta_ref, "missing");
3288 }
3289 Err(other) => {
3290 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3291 }
3292 Ok(_) => panic!("expected compile-time failure, got Ok"),
3293 }
3294 }
3295
3296 #[test]
3297 fn path_op_input_with_no_static_envelope_compiles_fine() {
3298 let agent = rustfn_agent("worker");
3299 let bp = minimal_bp(
3300 vec![agent],
3301 vec![],
3302 simple_flow(
3303 "worker",
3304 Expr::Path {
3305 at: "$.input".parse().expect("literal test path: $.input"),
3306 },
3307 ),
3308 );
3309 let compiler = Compiler::new(registry_with_echo());
3310 assert!(
3311 compiler.compile(&bp).is_ok(),
3312 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
3313 );
3314 }
3315}
3316
3317#[cfg(test)]
3319mod audit_agent_validation_tests {
3320 use super::*;
3321 use crate::worker::adapter::WorkerResult;
3322 use mlua_swarm_schema::{AuditDef, AuditMode};
3323
3324 fn registry_with_echo() -> SpawnerRegistry {
3325 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3326 Ok(WorkerResult {
3327 value: Value::String(inv.prompt),
3328 ok: true,
3329 stats: None,
3330 })
3331 });
3332 let mut reg = SpawnerRegistry::new();
3333 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3334 reg
3335 }
3336
3337 fn rustfn_agent(name: &str) -> AgentDef {
3338 AgentDef {
3339 name: name.to_string(),
3340 kind: AgentKind::RustFn,
3341 spec: serde_json::json!({ "fn_id": "echo" }),
3342 profile: None,
3343 meta: None,
3344 runner: None,
3345 runner_ref: None,
3346 verdict: None,
3347 lints: None,
3348 max_concurrency: None,
3349 }
3350 }
3351
3352 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
3353 Blueprint {
3354 schema_version: crate::blueprint::current_schema_version(),
3355 id: "audit-ref-ut".into(),
3356 flow: FlowNode::Step {
3357 ref_: "worker".to_string(),
3358 in_: Expr::Path {
3359 at: "$.input".parse().expect("literal test path: $.input"),
3360 },
3361 out: Expr::Path {
3362 at: "$.output".parse().expect("literal test path: $.output"),
3363 },
3364 },
3365 agents,
3366 operators: vec![],
3367 metas: vec![],
3368 hints: Default::default(),
3369 strategy: Default::default(),
3370 metadata: BlueprintMetadata::default(),
3371 spawner_hints: Default::default(),
3372 default_agent_kind: AgentKind::Operator,
3373 default_operator_kind: None,
3374 default_init_ctx: None,
3375 default_agent_ctx: None,
3376 default_context_policy: None,
3377 projection_placement: None,
3378 audits,
3379 degradation_policy: None,
3380 runners: vec![],
3381 default_runner: None,
3382 subprocesses: vec![],
3383 check_policy: None,
3384 blueprint_ref_includes: Vec::new(),
3385 }
3386 }
3387
3388 #[test]
3389 fn unresolved_audit_agent_is_a_loud_compile_error() {
3390 let bp = minimal_bp(
3391 vec![rustfn_agent("worker")],
3392 vec![AuditDef {
3393 agent: "missing-auditor".to_string(),
3394 steps: None,
3395 mode: AuditMode::default(),
3396 }],
3397 );
3398 let compiler = Compiler::new(registry_with_echo());
3399 match compiler.compile(&bp) {
3400 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
3401 assert_eq!(agent, "missing-auditor");
3402 assert_eq!(defined, vec!["worker".to_string()]);
3403 }
3404 Err(other) => {
3405 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
3406 }
3407 Ok(_) => panic!("expected compile-time failure, got Ok"),
3408 }
3409 }
3410
3411 #[test]
3412 fn resolved_audit_agent_compiles_fine() {
3413 let bp = minimal_bp(
3414 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
3415 vec![AuditDef {
3416 agent: "auditor".to_string(),
3417 steps: None,
3418 mode: AuditMode::default(),
3419 }],
3420 );
3421 let compiler = Compiler::new(registry_with_echo());
3422 assert!(
3423 compiler.compile(&bp).is_ok(),
3424 "an audits[].agent that names a declared AgentDef must compile"
3425 );
3426 }
3427}
3428
3429#[cfg(test)]
3438mod operator_ref_resolution_tests {
3439 use super::*;
3440 use crate::core::ctx::Ctx;
3441 use crate::types::CapToken;
3442 use crate::worker::adapter::{WorkerError, WorkerResult};
3443 use std::sync::Mutex;
3444
3445 type Seen = Arc<Mutex<Vec<(String, Option<Value>)>>>;
3447
3448 struct RecordingOperatorFactory {
3452 seen: Seen,
3453 }
3454
3455 impl SpawnerFactory for RecordingOperatorFactory {
3456 fn build(
3457 &self,
3458 agent_def: &AgentDef,
3459 hint: Option<&Value>,
3460 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3461 self.seen
3462 .lock()
3463 .expect("RecordingOperatorFactory.seen poisoned")
3464 .push((agent_def.name.clone(), hint.cloned()));
3465 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3466 let worker: WorkerFn = Arc::new(|_inv| {
3467 Box::pin(async move {
3468 Ok(WorkerResult {
3469 value: Value::Null,
3470 ok: true,
3471 stats: None,
3472 })
3473 })
3474 });
3475 spawner.registry.insert(agent_def.name.clone(), worker);
3476 Ok(Arc::new(spawner))
3477 }
3478 }
3479
3480 impl SpawnerFactoryKind for RecordingOperatorFactory {
3481 const KIND: AgentKind = AgentKind::Operator;
3482 type Worker = crate::operator::OperatorWorker;
3483 }
3484
3485 struct RecordingLuaFactory {
3488 seen: Seen,
3489 }
3490
3491 impl SpawnerFactory for RecordingLuaFactory {
3492 fn build(
3493 &self,
3494 agent_def: &AgentDef,
3495 hint: Option<&Value>,
3496 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3497 self.seen
3498 .lock()
3499 .expect("RecordingLuaFactory.seen poisoned")
3500 .push((agent_def.name.clone(), hint.cloned()));
3501 let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3502 let worker: WorkerFn = Arc::new(|_inv| {
3503 Box::pin(async move {
3504 Ok(WorkerResult {
3505 value: Value::Null,
3506 ok: true,
3507 stats: None,
3508 })
3509 })
3510 });
3511 spawner.registry.insert(agent_def.name.clone(), worker);
3512 Ok(Arc::new(spawner))
3513 }
3514 }
3515
3516 impl SpawnerFactoryKind for RecordingLuaFactory {
3517 const KIND: AgentKind = AgentKind::Lua;
3518 type Worker = LuaWorker;
3519 }
3520
3521 fn recording_compiler() -> (Compiler, Seen, Seen) {
3522 let operator_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3523 let lua_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3524 let mut registry = SpawnerRegistry::new();
3525 registry.register::<RecordingOperatorFactory>(Arc::new(RecordingOperatorFactory {
3526 seen: operator_seen.clone(),
3527 }));
3528 registry.register::<RecordingLuaFactory>(Arc::new(RecordingLuaFactory {
3529 seen: lua_seen.clone(),
3530 }));
3531 (Compiler::new(registry), operator_seen, lua_seen)
3532 }
3533
3534 fn bp_with_operator_and_lua_agents() -> Blueprint {
3538 serde_json::from_value(serde_json::json!({
3539 "schema_version": crate::blueprint::current_schema_version(),
3540 "id": "operator-pin-ut",
3541 "flow": {
3542 "kind": "step",
3543 "ref": "planner",
3544 "in": { "op": "path", "at": "$.input" },
3545 "out": { "op": "path", "at": "$.output" }
3546 },
3547 "agents": [
3548 {
3549 "name": "planner",
3550 "kind": "operator",
3551 "spec": { "operator_ref": "main-ai" }
3552 },
3553 {
3554 "name": "scorer",
3555 "kind": "lua",
3556 "spec": { "source": "return { value = 1, ok = true }" }
3557 }
3558 ],
3559 "operators": [{ "name": "main-ai" }],
3560 "hints": { "per_agent": { "planner": { "authored": "keep-me" } } },
3561 "strategy": { "strict_refs": false }
3562 }))
3563 .expect("test Blueprint literal")
3564 }
3565
3566 fn hint_for(seen: &Seen, agent: &str) -> Option<Value> {
3567 seen.lock()
3568 .expect("seen poisoned")
3569 .iter()
3570 .find(|(name, _)| name == agent)
3571 .map(|(_, hint)| hint.clone())
3572 .expect("agent was never built")
3573 }
3574
3575 #[test]
3580 fn the_compile_hands_the_factory_the_authored_hint_untouched() {
3581 let (compiler, operator_seen, lua_seen) = recording_compiler();
3582 let bp = bp_with_operator_and_lua_agents();
3583 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3584 compiler.compile_bound(&bp, &bound).expect("compile");
3585
3586 assert_eq!(
3587 hint_for(&operator_seen, "planner"),
3588 Some(serde_json::json!({ "authored": "keep-me" })),
3589 "the compile must hand over the authored hint verbatim"
3590 );
3591 assert_eq!(
3592 hint_for(&lua_seen, "scorer"),
3593 None,
3594 "an agent with no authored hint must still be built with None"
3595 );
3596 }
3597
3598 #[test]
3601 fn a_non_object_authored_hint_is_none_of_the_compilers_business() {
3602 let (compiler, _operator_seen, _lua_seen) = recording_compiler();
3603 let mut bp = bp_with_operator_and_lua_agents();
3604 bp.hints
3605 .per_agent
3606 .insert("planner".to_string(), Value::String("not-an-object".into()));
3607 let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3608 assert!(
3609 compiler.compile_bound(&bp, &bound).is_ok(),
3610 "the compile must accept whatever hint shape the author declared"
3611 );
3612 }
3613
3614 struct StubOperator {
3620 requires_binding: bool,
3621 }
3622
3623 #[async_trait]
3624 impl Operator for StubOperator {
3625 async fn execute(
3626 &self,
3627 _ctx: &Ctx,
3628 _system: Option<String>,
3629 _prompt: Value,
3630 _worker: Option<WorkerBinding>,
3631 _worker_token: CapToken,
3632 ) -> Result<WorkerResult, WorkerError> {
3633 Ok(WorkerResult {
3634 value: Value::Null,
3635 ok: true,
3636 stats: None,
3637 })
3638 }
3639
3640 fn requires_worker_binding(&self) -> bool {
3641 self.requires_binding
3642 }
3643 }
3644
3645 fn operator_agent() -> AgentDef {
3646 AgentDef {
3647 name: "planner".to_string(),
3648 kind: AgentKind::Operator,
3649 spec: serde_json::json!({ "operator_ref": "main-ai" }),
3650 profile: None,
3651 meta: None,
3652 runner: None,
3653 runner_ref: None,
3654 verdict: None,
3655 lints: None,
3656 max_concurrency: None,
3657 }
3658 }
3659
3660 struct StubResolver {
3664 seats: Vec<&'static str>,
3665 asked: Mutex<Vec<String>>,
3666 }
3667
3668 impl OperatorSlotResolver for StubResolver {
3669 fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>> {
3670 self.asked
3671 .lock()
3672 .expect("StubResolver.asked poisoned")
3673 .push(slot.to_string());
3674 self.seats.contains(&slot).then(|| {
3675 Arc::new(StubOperator {
3676 requires_binding: false,
3677 }) as Arc<dyn Operator>
3678 })
3679 }
3680 }
3681
3682 #[test]
3687 fn an_installed_resolver_answers_the_seat_and_the_registry_is_not_consulted() {
3688 let factory = OperatorSpawnerFactory::new();
3689 factory.register_operator(
3690 "main-ai",
3691 Arc::new(StubOperator {
3692 requires_binding: true,
3693 }) as Arc<dyn Operator>,
3694 );
3695 let resolver = Arc::new(StubResolver {
3696 seats: vec!["main-ai"],
3697 asked: Mutex::new(Vec::new()),
3698 });
3699 factory.set_slot_resolver(resolver.clone());
3700
3701 assert!(
3702 factory.build(&operator_agent(), None).is_ok(),
3703 "the installed resolver must answer the seat, not the registry entry \
3704 registered under the same name"
3705 );
3706 assert_eq!(
3707 *resolver.asked.lock().expect("asked"),
3708 vec!["main-ai".to_string()],
3709 "the resolver is asked for the seat the AgentDef declares"
3710 );
3711 }
3712
3713 #[test]
3717 fn a_resolver_miss_fails_loud_and_never_falls_back_to_the_registry() {
3718 let factory = OperatorSpawnerFactory::new();
3719 factory.register_operator(
3720 "main-ai",
3721 Arc::new(StubOperator {
3722 requires_binding: false,
3723 }) as Arc<dyn Operator>,
3724 );
3725 factory.set_slot_resolver(Arc::new(StubResolver {
3726 seats: vec!["some-other-seat"],
3727 asked: Mutex::new(Vec::new()),
3728 }));
3729
3730 match factory.build(&operator_agent(), None) {
3731 Err(CompileError::InvalidSpec { name, msg }) => {
3732 assert_eq!(name, "planner");
3733 assert!(
3734 msg.contains("main-ai"),
3735 "message must name the seat that went unserved: {msg}"
3736 );
3737 assert!(
3738 msg.contains("OperatorSlotResolver"),
3739 "message must say which side refused, so the wiring is the \
3740 obvious suspect: {msg}"
3741 );
3742 }
3743 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3744 Ok(_) => panic!(
3745 "an unserved seat must fail the compile, not silently resolve the \
3746 registry entry"
3747 ),
3748 }
3749 }
3750
3751 #[test]
3754 fn without_a_resolver_the_registry_answers_with_the_historical_message() {
3755 let factory = OperatorSpawnerFactory::new();
3756 match factory.build(&operator_agent(), None) {
3757 Err(CompileError::InvalidSpec { msg, .. }) => {
3758 assert!(
3759 msg.contains("operator_ref 'main-ai' not registered in factory"),
3760 "the registry-side message must stay the historical one: {msg}"
3761 );
3762 }
3763 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3764 Ok(_) => panic!("an unregistered seat must still fail"),
3765 }
3766 factory.register_operator(
3767 "main-ai",
3768 Arc::new(StubOperator {
3769 requires_binding: false,
3770 }) as Arc<dyn Operator>,
3771 );
3772 assert!(
3773 factory.build(&operator_agent(), None).is_ok(),
3774 "a registered backend must still resolve the seat directly"
3775 );
3776 }
3777}
3778
3779#[cfg(test)]
3782mod projection_placement_compile_tests {
3783 use super::*;
3784 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
3785 use crate::worker::adapter::WorkerResult;
3786 use mlua_swarm_schema::ProjectionPlacementSpec;
3787
3788 fn registry_with_echo() -> SpawnerRegistry {
3789 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3790 Ok(WorkerResult {
3791 value: Value::String(inv.prompt),
3792 ok: true,
3793 stats: None,
3794 })
3795 });
3796 let mut reg = SpawnerRegistry::new();
3797 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3798 reg
3799 }
3800
3801 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
3802 Blueprint {
3803 schema_version: crate::blueprint::current_schema_version(),
3804 id: "projection-placement-ut".into(),
3805 flow: FlowNode::Step {
3806 ref_: "worker".to_string(),
3807 in_: Expr::Path {
3808 at: "$.input".parse().expect("literal test path: $.input"),
3809 },
3810 out: Expr::Path {
3811 at: "$.output".parse().expect("literal test path: $.output"),
3812 },
3813 },
3814 agents: vec![AgentDef {
3815 name: "worker".to_string(),
3816 kind: AgentKind::RustFn,
3817 spec: serde_json::json!({ "fn_id": "echo" }),
3818 profile: None,
3819 meta: None,
3820 runner: None,
3821 runner_ref: None,
3822 verdict: None,
3823 lints: None,
3824 max_concurrency: None,
3825 }],
3826 operators: vec![],
3827 metas: vec![],
3828 hints: Default::default(),
3829 strategy: Default::default(),
3830 metadata: BlueprintMetadata::default(),
3831 spawner_hints: Default::default(),
3832 default_agent_kind: AgentKind::Operator,
3833 default_operator_kind: None,
3834 default_init_ctx: None,
3835 default_agent_ctx: None,
3836 default_context_policy: None,
3837 projection_placement,
3838 audits: vec![],
3839 degradation_policy: None,
3840 runners: vec![],
3841 default_runner: None,
3842 subprocesses: vec![],
3843 check_policy: None,
3844 blueprint_ref_includes: Vec::new(),
3845 }
3846 }
3847
3848 #[test]
3849 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
3850 let bp = minimal_bp(None);
3851 let compiled = Compiler::new(registry_with_echo())
3852 .compile(&bp)
3853 .expect("undeclared projection_placement compiles");
3854 assert_eq!(
3855 *compiled.projection_placement,
3856 ProjectionPlacement::default()
3857 );
3858 }
3859
3860 #[test]
3861 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
3862 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3863 root: Some("project_root".to_string()),
3864 dir_template: Some("custom/{task_id}/out".to_string()),
3865 }));
3866 let compiled = Compiler::new(registry_with_echo())
3867 .compile(&bp)
3868 .expect("valid projection_placement compiles");
3869 assert_eq!(
3870 compiled.projection_placement.root_preference,
3871 RootPreference::ProjectRoot
3872 );
3873 assert_eq!(
3874 compiled.projection_placement.dir_template,
3875 "custom/{task_id}/out"
3876 );
3877 }
3878
3879 #[test]
3880 fn declared_invalid_dir_template_rejects_compile() {
3881 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3882 root: None,
3883 dir_template: Some("workspace/tasks/ctx".to_string()), }));
3885 match Compiler::new(registry_with_echo()).compile(&bp) {
3886 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3887 Err(other) => {
3888 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3889 }
3890 Ok(_) => {
3891 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
3892 }
3893 }
3894 }
3895
3896 #[test]
3897 fn declared_invalid_root_literal_rejects_compile() {
3898 let bp = minimal_bp(Some(ProjectionPlacementSpec {
3899 root: Some("nope".to_string()),
3900 dir_template: None,
3901 }));
3902 match Compiler::new(registry_with_echo()).compile(&bp) {
3903 Err(CompileError::InvalidProjectionPlacement(_)) => {}
3904 Err(other) => {
3905 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3906 }
3907 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
3908 }
3909 }
3910}
3911
3912#[cfg(test)]
3914mod verdict_contract_lint_tests {
3915 use super::*;
3916 use crate::worker::adapter::WorkerResult;
3917
3918 fn registry_with_echo() -> SpawnerRegistry {
3919 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3920 Ok(WorkerResult {
3921 value: Value::String(inv.prompt),
3922 ok: true,
3923 stats: None,
3924 })
3925 });
3926 let mut reg = SpawnerRegistry::new();
3927 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3928 reg
3929 }
3930
3931 fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
3932 AgentDef {
3933 name: "gate".to_string(),
3934 kind: AgentKind::RustFn,
3935 spec: serde_json::json!({ "fn_id": "echo" }),
3936 profile: None,
3937 meta: None,
3938 runner: None,
3939 runner_ref: None,
3940 verdict,
3941 lints: None,
3942 max_concurrency: None,
3943 }
3944 }
3945
3946 fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
3947 Blueprint {
3948 schema_version: crate::blueprint::current_schema_version(),
3949 id: "verdict-contract-ut".into(),
3950 flow,
3951 agents: vec![agent],
3952 operators: vec![],
3953 metas: vec![],
3954 hints: Default::default(),
3955 strategy: Default::default(),
3956 metadata: BlueprintMetadata::default(),
3957 spawner_hints: Default::default(),
3958 default_agent_kind: AgentKind::Operator,
3959 default_operator_kind: None,
3960 default_init_ctx: None,
3961 default_agent_ctx: None,
3962 default_context_policy: None,
3963 projection_placement: None,
3964 audits: vec![],
3965 degradation_policy: None,
3966 runners: vec![],
3967 default_runner: None,
3968 subprocesses: vec![],
3969 check_policy: None,
3970 blueprint_ref_includes: Vec::new(),
3971 }
3972 }
3973
3974 fn step(ref_: &str, out_path: &str) -> FlowNode {
3975 FlowNode::Step {
3976 ref_: ref_.to_string(),
3977 in_: Expr::Lit { value: Value::Null },
3978 out: Expr::Path {
3979 at: out_path.parse().expect("literal test path"),
3980 },
3981 }
3982 }
3983
3984 fn noop() -> FlowNode {
3985 FlowNode::Seq { children: vec![] }
3986 }
3987
3988 fn eq_cond(path: &str, lit: &str) -> Expr {
3989 Expr::Eq {
3990 lhs: Box::new(Expr::Path {
3991 at: path.parse().expect("literal test path"),
3992 }),
3993 rhs: Box::new(Expr::Lit {
3994 value: Value::String(lit.to_string()),
3995 }),
3996 }
3997 }
3998
3999 fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
4000 FlowNode::Branch {
4001 cond,
4002 then_: Box::new(then_),
4003 else_: Box::new(else_),
4004 }
4005 }
4006
4007 fn body_contract(values: &[&str]) -> VerdictContract {
4008 VerdictContract {
4009 channel: VerdictChannel::Body,
4010 values: values.iter().map(|v| v.to_string()).collect(),
4011 }
4012 }
4013
4014 fn part_contract(values: &[&str]) -> VerdictContract {
4015 VerdictContract {
4016 channel: VerdictChannel::Part,
4017 values: values.iter().map(|v| v.to_string()).collect(),
4018 }
4019 }
4020
4021 #[test]
4022 fn contract_with_correct_body_channel_and_value_compiles() {
4023 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4024 let flow = FlowNode::Seq {
4025 children: vec![
4026 step("gate", "$.verdict"),
4027 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4028 ],
4029 };
4030 let bp = minimal_bp(agent, flow);
4031 assert!(
4032 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4033 "a cond addressing the bare step output must match a channel: \"body\" contract"
4034 );
4035 }
4036
4037 #[test]
4038 fn contract_with_correct_part_channel_and_value_compiles() {
4039 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4040 let flow = FlowNode::Seq {
4041 children: vec![
4042 step("gate", "$.gate"),
4043 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4044 ],
4045 };
4046 let bp = minimal_bp(agent, flow);
4047 assert!(
4048 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4049 "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
4050 );
4051 }
4052
4053 #[test]
4054 fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
4055 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4059 let flow = FlowNode::Seq {
4060 children: vec![
4061 step("gate", "$.gate"),
4062 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4063 ],
4064 };
4065 let bp = minimal_bp(agent, flow);
4066 match Compiler::new(registry_with_echo()).compile(&bp) {
4067 Err(CompileError::VerdictChannelMismatch {
4068 where_,
4069 agent,
4070 expected_channel,
4071 actual_shape,
4072 }) => {
4073 assert_eq!(agent, "gate");
4074 assert_eq!(expected_channel, "body");
4075 assert_eq!(actual_shape, "part");
4076 assert!(where_.contains("Branch cond"), "where_: {where_}");
4077 }
4078 Err(other) => {
4079 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4080 }
4081 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4082 }
4083 }
4084
4085 #[test]
4086 fn part_channel_contract_rejects_cond_addressing_bare_output() {
4087 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4090 let flow = FlowNode::Seq {
4091 children: vec![
4092 step("gate", "$.verdict"),
4093 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4094 ],
4095 };
4096 let bp = minimal_bp(agent, flow);
4097 match Compiler::new(registry_with_echo()).compile(&bp) {
4098 Err(CompileError::VerdictChannelMismatch {
4099 agent,
4100 expected_channel,
4101 actual_shape,
4102 ..
4103 }) => {
4104 assert_eq!(agent, "gate");
4105 assert_eq!(expected_channel, "part");
4106 assert_eq!(actual_shape, "body");
4107 }
4108 Err(other) => {
4109 panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4110 }
4111 Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4112 }
4113 }
4114
4115 #[test]
4116 fn contract_rejects_lit_outside_declared_values() {
4117 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4118 let flow = FlowNode::Seq {
4119 children: vec![
4120 step("gate", "$.verdict"),
4121 branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
4122 ],
4123 };
4124 let bp = minimal_bp(agent, flow);
4125 match Compiler::new(registry_with_echo()).compile(&bp) {
4126 Err(CompileError::VerdictValueNotInContract {
4127 agent,
4128 value,
4129 values,
4130 ..
4131 }) => {
4132 assert_eq!(agent, "gate");
4133 assert_eq!(value, "UNKNOWN");
4134 assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4135 }
4136 Err(other) => {
4137 panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
4138 }
4139 Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
4140 }
4141 }
4142
4143 #[test]
4144 fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
4145 let agent = gate_agent(None);
4146 let flow = FlowNode::Seq {
4147 children: vec![
4148 step("gate", "$.verdict"),
4149 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4150 ],
4151 };
4152 let bp = minimal_bp(agent, flow);
4153 assert!(
4154 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4155 "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
4156 );
4157 }
4158
4159 #[test]
4160 fn in_expr_with_lit_haystack_members_compiles() {
4161 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4162 let cond = Expr::In {
4163 needle: Box::new(Expr::Path {
4164 at: "$.verdict".parse().expect("literal test path"),
4165 }),
4166 haystack: Box::new(Expr::Lit {
4167 value: serde_json::json!(["PASS", "BLOCKED"]),
4168 }),
4169 };
4170 let flow = FlowNode::Seq {
4171 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4172 };
4173 let bp = minimal_bp(agent, flow);
4174 assert!(
4175 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4176 "an `In` haystack whose every Lit is a declared value must compile"
4177 );
4178 }
4179
4180 #[test]
4187 fn strict_mode_rejects_unhandled_declared_value() {
4188 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4189 let flow = FlowNode::Seq {
4190 children: vec![
4191 step("gate", "$.verdict"),
4192 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4193 ],
4194 };
4195 let mut bp = minimal_bp(agent, flow);
4196 bp.metadata.strict_verdict_handling = Some(true);
4197 match Compiler::new(registry_with_echo()).compile(&bp) {
4198 Err(CompileError::VerdictValueUnhandled {
4199 agent,
4200 value,
4201 declared_values,
4202 step_ref,
4203 }) => {
4204 assert_eq!(agent, "gate");
4205 assert_eq!(value, "PASS");
4206 assert_eq!(
4207 declared_values,
4208 vec!["PASS".to_string(), "BLOCKED".to_string()]
4209 );
4210 assert_eq!(step_ref, "gate");
4211 }
4212 Err(other) => {
4213 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4214 }
4215 Ok(_) => panic!(
4216 "expected compile-time rejection for a declared verdict value with no \
4217 downstream handler under strict_verdict_handling=Some(true)"
4218 ),
4219 }
4220 }
4221
4222 #[test]
4229 fn default_mode_permits_unhandled_declared_value() {
4230 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4231 let flow = FlowNode::Seq {
4232 children: vec![
4233 step("gate", "$.verdict"),
4234 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4235 ],
4236 };
4237 let bp = minimal_bp(agent, flow);
4238 assert!(
4240 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4241 "default mode must never reject a Blueprint for unhandled declared values \
4242 (opt-in, back-compat with GH #50)"
4243 );
4244 }
4245
4246 #[test]
4251 fn strict_mode_accepts_all_declared_values_handled() {
4252 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4253 let flow = FlowNode::Seq {
4256 children: vec![
4257 step("gate", "$.verdict"),
4258 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4259 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4260 ],
4261 };
4262 let mut bp = minimal_bp(agent, flow);
4263 bp.metadata.strict_verdict_handling = Some(true);
4264 assert!(
4265 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4266 "strict mode must accept a Blueprint that handles every declared value"
4267 );
4268 }
4269
4270 #[test]
4274 fn strict_mode_accepts_declared_values_covered_by_in_expr() {
4275 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4276 let cond = Expr::In {
4277 needle: Box::new(Expr::Path {
4278 at: "$.verdict".parse().expect("literal test path"),
4279 }),
4280 haystack: Box::new(Expr::Lit {
4281 value: serde_json::json!(["PASS", "BLOCKED"]),
4282 }),
4283 };
4284 let flow = FlowNode::Seq {
4285 children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4286 };
4287 let mut bp = minimal_bp(agent, flow);
4288 bp.metadata.strict_verdict_handling = Some(true);
4289 assert!(
4290 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4291 "strict mode must accept an `In` haystack that covers every declared value"
4292 );
4293 }
4294
4295 #[test]
4299 fn strict_mode_rejects_unhandled_part_channel_value() {
4300 let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4301 let flow = FlowNode::Seq {
4302 children: vec![
4303 step("gate", "$.gate"),
4304 branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4305 ],
4306 };
4307 let mut bp = minimal_bp(agent, flow);
4308 bp.metadata.strict_verdict_handling = Some(true);
4309 match Compiler::new(registry_with_echo()).compile(&bp) {
4310 Err(CompileError::VerdictValueUnhandled {
4311 agent,
4312 value,
4313 step_ref,
4314 ..
4315 }) => {
4316 assert_eq!(agent, "gate");
4317 assert_eq!(value, "PASS");
4318 assert_eq!(step_ref, "gate");
4319 }
4320 Err(other) => {
4321 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4322 }
4323 Ok(_) => panic!(
4324 "expected compile-time rejection for a declared verdict value with no \
4325 downstream handler (part channel) under strict_verdict_handling=Some(true)"
4326 ),
4327 }
4328 }
4329
4330 fn lints(
4336 pairs: &[(&str, mlua_swarm_schema::LintSetting)],
4337 ) -> Option<std::collections::BTreeMap<String, mlua_swarm_schema::LintSetting>> {
4338 Some(
4339 pairs
4340 .iter()
4341 .map(|(key, setting)| ((*key).to_string(), *setting))
4342 .collect(),
4343 )
4344 }
4345
4346 fn bp_with_unhandled_value() -> Blueprint {
4350 let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4351 let flow = FlowNode::Seq {
4352 children: vec![
4353 step("gate", "$.verdict"),
4354 branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4355 ],
4356 };
4357 minimal_bp(agent, flow)
4358 }
4359
4360 fn named_agent(name: &str, verdict: Option<VerdictContract>) -> AgentDef {
4364 AgentDef {
4365 name: name.to_string(),
4366 ..gate_agent(verdict)
4367 }
4368 }
4369
4370 fn bp_with_two_unhandled_agents() -> Blueprint {
4374 let flow = FlowNode::Seq {
4375 children: vec![
4376 step("researcher", "$.researcher_verdict"),
4377 step("reviewer", "$.reviewer_verdict"),
4378 branch(eq_cond("$.researcher_verdict", "BLOCKED"), noop(), noop()),
4379 branch(eq_cond("$.reviewer_verdict", "BLOCKED"), noop(), noop()),
4380 ],
4381 };
4382 let mut bp = minimal_bp(
4383 named_agent("researcher", Some(body_contract(&["PASS", "BLOCKED"]))),
4384 flow,
4385 );
4386 bp.agents.push(named_agent(
4387 "reviewer",
4388 Some(body_contract(&["PASS", "BLOCKED"])),
4389 ));
4390 bp
4391 }
4392
4393 #[test]
4398 fn agent_lints_deny_rejects_only_the_declaring_agent() {
4399 let mut bp = bp_with_two_unhandled_agents();
4400 bp.agents[0].lints = lints(&[(
4401 "verdict-value-unhandled",
4402 mlua_swarm_schema::LintSetting::Deny,
4403 )]);
4404 match Compiler::new(registry_with_echo()).compile(&bp) {
4405 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4406 assert_eq!(agent, "researcher", "the sibling only warns");
4407 assert_eq!(value, "PASS");
4408 }
4409 Err(other) => {
4410 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4411 }
4412 Ok(_) => panic!(
4413 "expected compile-time rejection under \
4414 agents[0].lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4415 ),
4416 }
4417 }
4418
4419 #[test]
4423 fn agent_allow_beats_blueprint_deny_for_that_agent() {
4424 let mut bp = bp_with_two_unhandled_agents();
4425 bp.metadata.lints = lints(&[(
4426 "verdict-value-unhandled",
4427 mlua_swarm_schema::LintSetting::Deny,
4428 )]);
4429 bp.agents[0].lints = lints(&[(
4430 "verdict-value-unhandled",
4431 mlua_swarm_schema::LintSetting::Allow,
4432 )]);
4433 match Compiler::new(registry_with_echo()).compile(&bp) {
4434 Err(CompileError::VerdictValueUnhandled { agent, .. }) => {
4435 assert_eq!(
4436 agent, "reviewer",
4437 "the allowing agent is silenced; the sibling still denies"
4438 );
4439 }
4440 Err(other) => {
4441 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4442 }
4443 Ok(_) => panic!("the sibling agent's Blueprint-level deny must still reject"),
4444 }
4445 }
4446
4447 #[test]
4451 fn strict_flag_wins_over_agent_lints_allow() {
4452 let mut bp = bp_with_unhandled_value();
4453 bp.metadata.strict_verdict_handling = Some(true);
4454 bp.agents[0].lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4455 match Compiler::new(registry_with_echo()).compile(&bp) {
4456 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(agent, "gate"),
4457 Err(other) => {
4458 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4459 }
4460 Ok(_) => panic!(
4461 "strict_verdict_handling=Some(true) must still reject under an agent-level allow"
4462 ),
4463 }
4464 }
4465
4466 #[test]
4469 fn agent_category_key_reaches_the_kind() {
4470 let mut bp = bp_with_two_unhandled_agents();
4471 bp.agents[0].lints =
4472 lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4473 match Compiler::new(registry_with_echo()).compile(&bp) {
4474 Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(
4475 agent, "researcher",
4476 "a category: group deny must reach the kind it covers, on the declaring agent"
4477 ),
4478 Err(other) => {
4479 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4480 }
4481 Ok(_) => panic!("expected compile-time rejection under an agent-level category deny"),
4482 }
4483 }
4484
4485 #[test]
4488 fn agent_without_lints_inherits_the_blueprint_layer() {
4489 let mut bp = bp_with_two_unhandled_agents();
4490 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4491 assert!(
4492 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4493 "a Blueprint-wide allow covers every agent that declares nothing"
4494 );
4495
4496 let gates = resolve_unhandled_verdict_gates(&bp);
4497 assert_eq!(gates.for_agent("reviewer"), UnhandledVerdictGate::Silence);
4498 assert!(gates.all_silent());
4499
4500 bp.agents[0].lints = lints(&[(
4501 "verdict-value-unhandled",
4502 mlua_swarm_schema::LintSetting::Warn,
4503 )]);
4504 let gates = resolve_unhandled_verdict_gates(&bp);
4505 assert_eq!(
4506 gates.for_agent("researcher"),
4507 UnhandledVerdictGate::Warn,
4508 "the agent's own layer wins over the Blueprint's allow"
4509 );
4510 assert_eq!(
4511 gates.for_agent("reviewer"),
4512 UnhandledVerdictGate::Silence,
4513 "the sibling keeps the Blueprint layer"
4514 );
4515 assert!(!gates.all_silent());
4516 }
4517
4518 #[test]
4522 fn lints_deny_rejects_unhandled_declared_value() {
4523 let mut bp = bp_with_unhandled_value();
4524 bp.metadata.lints = lints(&[(
4525 "verdict-value-unhandled",
4526 mlua_swarm_schema::LintSetting::Deny,
4527 )]);
4528 match Compiler::new(registry_with_echo()).compile(&bp) {
4529 Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4530 assert_eq!(agent, "gate");
4531 assert_eq!(value, "PASS");
4532 }
4533 Err(other) => {
4534 panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4535 }
4536 Ok(_) => panic!(
4537 "expected compile-time rejection under \
4538 metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4539 ),
4540 }
4541 }
4542
4543 #[test]
4546 fn lints_category_deny_rejects_unhandled_declared_value() {
4547 let mut bp = bp_with_unhandled_value();
4548 bp.metadata.lints = lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4549 assert!(
4550 matches!(
4551 Compiler::new(registry_with_echo()).compile(&bp),
4552 Err(CompileError::VerdictValueUnhandled { .. })
4553 ),
4554 "a category: group deny must reach the kind it covers"
4555 );
4556 }
4557
4558 #[test]
4562 fn lints_allow_compiles_and_silences_the_warn() {
4563 let mut bp = bp_with_unhandled_value();
4564 bp.metadata.lints = lints(&[(
4565 "verdict-value-unhandled",
4566 mlua_swarm_schema::LintSetting::Allow,
4567 )]);
4568 assert!(
4569 Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4570 "an allowed lint must never reject the compile"
4571 );
4572 assert_eq!(
4573 resolve_unhandled_verdict_gate(&bp.metadata),
4574 UnhandledVerdictGate::Silence
4575 );
4576 }
4577
4578 #[test]
4582 fn strict_flag_wins_over_lints_allow() {
4583 let mut bp = bp_with_unhandled_value();
4584 bp.metadata.strict_verdict_handling = Some(true);
4585 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4586 assert!(
4587 matches!(
4588 Compiler::new(registry_with_echo()).compile(&bp),
4589 Err(CompileError::VerdictValueUnhandled { .. })
4590 ),
4591 "strict_verdict_handling=Some(true) must still reject under a lints allow"
4592 );
4593 }
4594
4595 #[test]
4598 fn unhandled_verdict_gate_resolution_table() {
4599 use mlua_swarm_schema::LintSetting;
4600
4601 let gate = |strict, map| {
4602 resolve_unhandled_verdict_gate(&BlueprintMetadata {
4603 strict_verdict_handling: strict,
4604 lints: map,
4605 ..Default::default()
4606 })
4607 };
4608 let kind = "verdict-value-unhandled";
4609
4610 assert_eq!(gate(None, None), UnhandledVerdictGate::Warn);
4611 assert_eq!(gate(Some(false), None), UnhandledVerdictGate::Warn);
4612 assert_eq!(gate(Some(true), None), UnhandledVerdictGate::Deny);
4613 assert_eq!(
4614 gate(None, lints(&[(kind, LintSetting::Deny)])),
4615 UnhandledVerdictGate::Deny
4616 );
4617 assert_eq!(
4618 gate(None, lints(&[(kind, LintSetting::Warn)])),
4619 UnhandledVerdictGate::Warn
4620 );
4621 assert_eq!(
4622 gate(None, lints(&[(kind, LintSetting::Allow)])),
4623 UnhandledVerdictGate::Silence
4624 );
4625 assert_eq!(
4626 gate(Some(true), lints(&[(kind, LintSetting::Allow)])),
4627 UnhandledVerdictGate::Deny,
4628 "strict wins over allow"
4629 );
4630 assert_eq!(
4632 gate(
4633 None,
4634 lints(&[
4635 (kind, LintSetting::Allow),
4636 ("category:suspicious", LintSetting::Deny),
4637 ])
4638 ),
4639 UnhandledVerdictGate::Silence
4640 );
4641 assert_eq!(
4644 gate(None, lints(&[("no-such-lint", LintSetting::Deny)])),
4645 UnhandledVerdictGate::Warn
4646 );
4647 }
4648
4649 #[test]
4653 fn lints_never_soften_other_compile_errors() {
4654 let mut bp = bp_with_unhandled_value();
4655 bp.agents.push(gate_agent(None));
4656 bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4657 assert!(
4658 matches!(
4659 Compiler::new(registry_with_echo()).compile(&bp),
4660 Err(CompileError::DuplicateAgent(name)) if name == "gate"
4661 ),
4662 "an `all` allow must not suppress a compile hard error"
4663 );
4664 }
4665
4666 #[test]
4673 fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
4674 let agent = gate_agent(None);
4675 let flow = FlowNode::Seq {
4676 children: vec![
4677 step("gate", "$.verdict"),
4678 FlowNode::Loop {
4679 counter: Expr::Path {
4680 at: "$.n".parse().expect("literal test path"),
4681 },
4682 cond: eq_cond("$.verdict", "BLOCKED"),
4683 body: Box::new(step("gate", "$.verdict")),
4684 max: 3,
4685 },
4686 branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4687 ],
4688 };
4689 let bp = minimal_bp(agent, flow);
4690 let compiled = Compiler::new(registry_with_echo())
4691 .compile(&bp)
4692 .expect("a verdict-omitted Blueprint must compile unchanged");
4693 assert!(
4694 compiled.router.verdict_contracts.is_empty(),
4695 "no agent declared a verdict contract"
4696 );
4697 }
4698
4699 #[test]
4706 fn every_compile_error_diagnostic_kind_is_a_declared_lint() {
4707 let kinds = [
4708 "bound-agent-resolution",
4709 "unknown-agent-kind",
4710 "invalid-agent-spec",
4711 "worker-binding-missing",
4712 "unresolved-agent-ref",
4713 "duplicate-agent-name",
4714 "unresolved-operator-ref",
4715 "unresolved-meta-ref",
4716 "step-naming-collision",
4717 "invalid-projection-placement",
4718 "unresolved-audit-agent",
4719 "verdict-channel-mismatch",
4720 "verdict-value-not-in-contract",
4721 "verdict-value-unhandled",
4722 "removed-spawner-hint",
4723 ];
4724 for kind in kinds {
4725 assert!(
4726 mlua_swarm_diag::lint_decl(kind).is_some(),
4727 "kind '{kind}' emitted by From<&CompileError> has no LINT_DECLS entry"
4728 );
4729 }
4730 }
4731
4732 #[test]
4733 fn invalid_spec_with_worker_binding_prefix_specializes_the_diagnostic_kind() {
4734 let err = CompileError::InvalidSpec {
4738 name: "greeter".into(),
4739 msg: format!("{WORKER_BINDING_REQUIRED_MSG_PREFIX}. Fix by either: (a) ..."),
4740 };
4741 let d = mlua_swarm_diag::Diagnostic::from(&err);
4742 assert_eq!(d.kind, "worker-binding-missing");
4743 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4744 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4745 assert!(d.message.contains("greeter"));
4746 let suggestion = d
4747 .suggestion
4748 .expect("specialized arm must carry a suggestion");
4749 assert!(suggestion.patch.contains("backend = \"ws_operator\""));
4750 assert_eq!(
4751 suggestion.applicability,
4752 mlua_swarm_diag::Applicability::HasPlaceholders
4753 );
4754 assert_eq!(
4755 d.docs_ref.expect("docs_ref must be set").uri,
4756 "mse://guides/bp-dsl-templates"
4757 );
4758 match d.span.expect("span must be set").element {
4759 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "greeter"),
4760 other => panic!("expected Agent span, got {other:?}"),
4761 }
4762 }
4763
4764 fn bp_with_hint_layers(layers: &[&str]) -> Blueprint {
4774 let mut bp = minimal_bp(gate_agent(None), step("gate", "$.verdict"));
4775 bp.spawner_hints = mlua_swarm_schema::SpawnerHints {
4776 layers: layers.iter().map(|s| s.to_string()).collect(),
4777 };
4778 bp
4779 }
4780
4781 #[test]
4782 fn declaring_the_removed_operator_delegate_layer_fails_the_compile() {
4783 let bp = bp_with_hint_layers(&["operator_delegate"]);
4784 match Compiler::new(registry_with_echo()).compile(&bp) {
4785 Err(CompileError::RemovedSpawnerHint { key, reason }) => {
4786 assert_eq!(key, "operator_delegate");
4787 assert!(
4788 reason.contains("Run.current") && reason.contains("system"),
4789 "the reason must name both defects the removal was for, so the error \
4790 alone explains why: {reason}"
4791 );
4792 }
4793 Err(other) => panic!("expected RemovedSpawnerHint, got: {other}"),
4794 Ok(_) => panic!(
4795 "a Blueprint declaring the removed operator_delegate layer must not compile — \
4796 otherwise the layer silently stops applying"
4797 ),
4798 }
4799 }
4800
4801 #[test]
4807 fn an_unknown_but_not_removed_layer_key_still_compiles() {
4808 let bp = bp_with_hint_layers(&["main_ai", "some_deployment_specific_layer"]);
4809 Compiler::new(registry_with_echo())
4810 .compile(&bp)
4811 .expect("unknown (as opposed to removed) hint keys stay leniently skipped");
4812 }
4813
4814 #[test]
4815 fn a_blueprint_declaring_no_layers_compiles() {
4816 let bp = bp_with_hint_layers(&[]);
4817 Compiler::new(registry_with_echo())
4818 .compile(&bp)
4819 .expect("the all-clear case must produce no finding at all");
4820 }
4821
4822 #[test]
4823 fn removed_spawner_hint_projects_a_migration_diagnostic_naming_the_replacement() {
4824 let err = CompileError::RemovedSpawnerHint {
4825 key: "operator_delegate".into(),
4826 reason: "it could not follow a handover".into(),
4827 };
4828 let d = mlua_swarm_diag::Diagnostic::from(&err);
4829
4830 assert_eq!(d.kind, "removed-spawner-hint");
4831 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4832 assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4833
4834 let decl = mlua_swarm_diag::lint_decl("removed-spawner-hint")
4838 .expect("the kind must be declared in LINT_DECLS");
4839 assert_eq!(decl.category, mlua_swarm_diag::LintCategory::Migration);
4840
4841 let help = d.help.as_ref().expect("help must name the replacement");
4843 assert!(
4844 help.contains("operators[]")
4845 && help.contains("spec.operator_ref")
4846 && help.contains("operator_sid"),
4847 "the help line must name all three parts of the AgentSpec axis an author has to \
4848 write, not just say the old one is gone: {help}"
4849 );
4850
4851 let suggestion = d.suggestion.expect("a concrete patch must be attached");
4859 assert_eq!(
4860 suggestion,
4861 mlua_swarm_diag::removed_spawner_hint_suggestion()
4862 );
4863 assert!(
4864 suggestion.patch.contains("\"operator_ref\""),
4865 "whatever else the shared patch says, it has to show the field an author \
4866 must add: {}",
4867 suggestion.patch
4868 );
4869 assert_eq!(
4870 suggestion.applicability,
4871 mlua_swarm_diag::Applicability::HasPlaceholders
4874 );
4875
4876 assert_eq!(
4877 d.docs_ref.expect("docs_ref must be set").uri,
4878 "mse://guides/blueprint-authoring"
4879 );
4880 assert!(matches!(
4881 d.span.expect("span must be set").element,
4882 mlua_swarm_diag::DiagElement::BlueprintRoot
4883 ));
4884 }
4885
4886 #[test]
4887 fn generic_invalid_spec_maps_to_the_generic_kind() {
4888 let err = CompileError::InvalidSpec {
4889 name: "solo".into(),
4890 msg: "operator spec: 'operator_ref' (string) required".into(),
4891 };
4892 let d = mlua_swarm_diag::Diagnostic::from(&err);
4893 assert_eq!(d.kind, "invalid-agent-spec");
4894 assert!(
4895 d.suggestion.is_none(),
4896 "generic arm carries no canned patch"
4897 );
4898 }
4899
4900 #[test]
4901 fn verdict_value_not_in_contract_diagnostic_carries_suggestion_and_span() {
4902 let err = CompileError::VerdictValueNotInContract {
4903 where_: "Branch cond".into(),
4904 agent: "review".into(),
4905 value: "NOT_DECLARED".into(),
4906 values: vec!["PASS".into(), "BLOCKED".into()],
4907 };
4908 let d = mlua_swarm_diag::Diagnostic::from(&err);
4909 assert_eq!(d.kind, "verdict-value-not-in-contract");
4910 assert!(d.message.contains("NOT_DECLARED"));
4911 assert!(d.suggestion.is_some());
4912 match d.span.expect("span must be set").element {
4913 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "review"),
4914 other => panic!("expected Agent span, got {other:?}"),
4915 }
4916 }
4917
4918 #[test]
4919 fn invalid_max_concurrency_diagnostic_is_a_declared_error_on_the_agent() {
4920 let err = CompileError::InvalidMaxConcurrency {
4921 agent: "coder".into(),
4922 };
4923 let d = mlua_swarm_diag::Diagnostic::from(&err);
4924 assert_eq!(d.kind, "invalid-max-concurrency");
4925 assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4926 let decl = mlua_swarm_diag::lint_decl(d.kind).expect("kind must be declared");
4927 assert_eq!(decl.default_level, mlua_swarm_diag::DiagLevel::Error);
4928 assert!(d
4929 .suggestion
4930 .as_ref()
4931 .expect("suggestion")
4932 .patch
4933 .contains("max_concurrency"));
4934 let span = d.span.expect("span must be set");
4935 assert_eq!(
4936 span.json_path.as_deref(),
4937 Some("$.agents[?(@.name=='coder')].max_concurrency")
4938 );
4939 match span.element {
4940 mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "coder"),
4941 other => panic!("expected Agent span, got {other:?}"),
4942 }
4943 }
4944}
4945
4946#[cfg(test)]
4948mod subprocess_embed_compile_tests {
4949 use super::*;
4950 use mlua_swarm_schema::{current_schema_version, SubprocessDef, SubprocessOverrides};
4951
4952 fn subprocess_agent(name: &str, runner: Option<Runner>) -> AgentDef {
4953 AgentDef {
4954 name: name.to_string(),
4955 kind: AgentKind::Subprocess,
4956 spec: serde_json::json!({}),
4957 profile: Some(AgentProfile {
4958 system_prompt: "you are a headless worker".to_string(),
4959 model: Some("profile-model".to_string()),
4960 tools: vec!["Read".to_string()],
4961 ..Default::default()
4962 }),
4963 meta: None,
4964 runner,
4965 runner_ref: None,
4966 verdict: None,
4967 lints: None,
4968 max_concurrency: None,
4969 }
4970 }
4971
4972 fn echo_def(name: &str) -> SubprocessDef {
4973 SubprocessDef {
4974 name: name.to_string(),
4975 argv: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
4976 stdin: Some("{prompt}".to_string()),
4977 env: Default::default(),
4978 cwd: None,
4979 output: None,
4980 stream_mode: None,
4981 timeout_ms: None,
4982 }
4983 }
4984
4985 fn bp_with(agents: Vec<AgentDef>, subprocesses: Vec<SubprocessDef>) -> Blueprint {
4986 Blueprint {
4987 schema_version: current_schema_version(),
4988 id: "gh83-ut".into(),
4989 flow: FlowNode::Seq { children: vec![] },
4990 agents,
4991 operators: vec![],
4992 metas: vec![],
4993 hints: Default::default(),
4994 strategy: Default::default(),
4995 metadata: BlueprintMetadata::default(),
4996 spawner_hints: Default::default(),
4997 default_agent_kind: AgentKind::Operator,
4998 default_operator_kind: None,
4999 default_init_ctx: None,
5000 default_agent_ctx: None,
5001 default_context_policy: None,
5002 projection_placement: None,
5003 audits: vec![],
5004 degradation_policy: None,
5005 runners: vec![],
5006 default_runner: None,
5007 subprocesses,
5008 check_policy: None,
5009 blueprint_ref_includes: vec![],
5010 }
5011 }
5012
5013 fn subprocess_runner(template: &str) -> Runner {
5014 Runner::Subprocess {
5015 template: template.to_string(),
5016 overrides: SubprocessOverrides::default(),
5017 }
5018 }
5019
5020 #[test]
5021 fn validate_placeholders_accepts_closed_set_and_json_braces() {
5022 for ok in [
5023 "{system} {system_file} {prompt} {model} {tools_csv} {work_dir} {task_id} {attempt}",
5024 r#"echo '{"result": "ok", "nested": {"a": 1}}'"#,
5025 "no placeholders at all",
5026 "unmatched { brace",
5027 ] {
5028 validate_embed_placeholders(ok, "ut").expect("must be accepted");
5029 }
5030 }
5031
5032 #[test]
5033 fn validate_placeholders_rejects_unknown_token() {
5034 let err = validate_embed_placeholders("--flag {evil}", "argv[1]").unwrap_err();
5035 assert!(err.contains("'{evil}'"), "token named: {err}");
5036 assert!(err.contains("closed set"), "closed set listed: {err}");
5037 }
5038
5039 #[test]
5043 fn validate_placeholders_descends_into_literal_braces() {
5044 validate_embed_placeholders(r#"{"task": "{prompt}"}"#, "stdin")
5045 .expect("nested closed-set token must be accepted");
5046 let err = validate_embed_placeholders(r#"{"task": "{evil}"}"#, "stdin").unwrap_err();
5047 assert!(
5048 err.contains("'{evil}'"),
5049 "nested unknown token caught: {err}"
5050 );
5051 }
5052
5053 #[test]
5054 fn hint_resolution_finds_declared_template() {
5055 let agent = subprocess_agent("headless", Some(subprocess_runner("echo")));
5056 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5057 let hint = resolve_subprocess_template_hint(&bp, &agent)
5058 .expect("resolves")
5059 .expect("Runner::Subprocess must synthesize a hint");
5060 assert_eq!(hint[SUBPROCESS_TEMPLATE_HINT_KEY]["name"], "echo");
5061 assert!(hint.get(SUBPROCESS_OVERRIDES_HINT_KEY).is_some());
5062 }
5063
5064 #[test]
5065 fn hint_resolution_unknown_template_is_invalid_spec() {
5066 let agent = subprocess_agent("headless", Some(subprocess_runner("nope")));
5067 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5068 let err = resolve_subprocess_template_hint(&bp, &agent).unwrap_err();
5069 let msg = format!("{err}");
5070 assert!(msg.contains("'nope'"), "missing template named: {msg}");
5071 assert!(msg.contains("echo"), "defined templates listed: {msg}");
5072 }
5073
5074 #[test]
5075 fn hint_resolution_none_without_subprocess_runner() {
5076 let agent = subprocess_agent("headless", None);
5077 let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5078 let hint = resolve_subprocess_template_hint(&bp, &agent).expect("resolves");
5079 assert!(hint.is_none(), "spec-based agents keep the historical path");
5080 }
5081
5082 fn agent_block_agent(name: &str, runner: Option<Runner>, profile_tools: &[&str]) -> AgentDef {
5089 AgentDef {
5090 name: name.to_string(),
5091 kind: AgentKind::AgentBlock,
5092 spec: serde_json::json!({}),
5093 profile: Some(AgentProfile {
5094 system_prompt: "you are an in-process auditor".to_string(),
5095 tools: profile_tools.iter().map(|t| t.to_string()).collect(),
5096 ..Default::default()
5097 }),
5098 meta: None,
5099 runner,
5100 runner_ref: None,
5101 verdict: None,
5102 lints: None,
5103 max_concurrency: None,
5104 }
5105 }
5106
5107 fn agent_block_runner(tools: &[&str]) -> Runner {
5108 Runner::AgentBlockInProcess {
5109 tools: tools.iter().map(|t| t.to_string()).collect(),
5110 }
5111 }
5112
5113 #[test]
5119 fn agent_block_runner_tools_are_projected_over_profile_tools() {
5120 let agent = agent_block_agent(
5121 "auditor",
5122 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5123 &["Read"],
5124 );
5125 let bp = bp_with(vec![agent], vec![]);
5126 let bound = resolve_bound_agents(&bp).expect("binds");
5127 let effective = materialize_bound_blueprint(&bp, &bound);
5128 assert_eq!(
5129 effective.agents[0].profile.as_ref().unwrap().tools,
5130 vec!["mcp__outline__list_docs".to_string()],
5131 "the declared Runner tools replace profile.tools (['Read'])"
5132 );
5133 }
5134
5135 #[test]
5139 fn agent_block_projection_distinguishes_declared_empty_from_absent() {
5140 let declared = agent_block_agent("auditor", Some(agent_block_runner(&[])), &["Read"]);
5141 let bp = bp_with(vec![declared], vec![]);
5142 let bound = resolve_bound_agents(&bp).expect("binds");
5143 let effective = materialize_bound_blueprint(&bp, &bound);
5144 assert!(
5145 effective.agents[0]
5146 .profile
5147 .as_ref()
5148 .unwrap()
5149 .tools
5150 .is_empty(),
5151 "empty means enforced-empty, not 'unset'"
5152 );
5153
5154 let absent = agent_block_agent("auditor", None, &["Read"]);
5155 let bp = bp_with(vec![absent], vec![]);
5156 let bound = resolve_bound_agents(&bp).expect("binds");
5157 let effective = materialize_bound_blueprint(&bp, &bound);
5158 assert_eq!(
5159 effective.agents[0].profile.as_ref().unwrap().tools,
5160 vec!["Read".to_string()],
5161 "no Runner declared → the agent.md tools line stands"
5162 );
5163 }
5164
5165 #[test]
5172 fn compile_rejects_script_mode_with_a_declared_mcp_grant() {
5173 let mut agent = agent_block_agent(
5174 "auditor",
5175 Some(agent_block_runner(&["mcp__outline__list_docs"])),
5176 &[],
5177 );
5178 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5179 let mut bp = bp_with(vec![agent], vec![]);
5180 bp.strategy.strict_refs = false;
5181
5182 let mut registry = SpawnerRegistry::new();
5183 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5184 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5185 );
5186 let err = match Compiler::new(registry).compile(&bp) {
5188 Err(e) => e,
5189 Ok(_) => panic!("script mode + declared MCP grant must not compile"),
5190 };
5191 let msg = format!("{err}");
5192 assert!(msg.contains("script_path"), "names the trigger: {msg}");
5193 assert!(
5194 msg.contains("mcp__outline__list_docs"),
5195 "names the unenforceable tools: {msg}"
5196 );
5197 }
5198
5199 #[test]
5203 fn compile_accepts_script_mode_with_only_inert_tools() {
5204 let mut agent = agent_block_agent("auditor", None, &["Read", "WebSearch"]);
5205 agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5206 let mut bp = bp_with(vec![agent], vec![]);
5207 bp.strategy.strict_refs = false;
5208
5209 let mut registry = SpawnerRegistry::new();
5210 registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5211 Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5212 );
5213 if let Err(e) = Compiler::new(registry).compile(&bp) {
5214 panic!("inert tools must not trip the MCP-grant guard: {e}");
5215 }
5216 }
5217
5218 #[test]
5219 fn build_embed_rejects_unknown_placeholder() {
5220 let agent = subprocess_agent("headless", None);
5221 let mut def = echo_def("echo");
5222 def.argv.push("--x={evil}".to_string());
5223 let err = SubprocessProcessSpawnerFactory::build_embed(
5224 &agent,
5225 &serde_json::to_value(&def).unwrap(),
5226 None,
5227 )
5228 .unwrap_err();
5229 assert!(format!("{err}").contains("'{evil}'"));
5230 }
5231
5232 #[test]
5233 fn build_embed_rejects_output_with_stream_mode() {
5234 let agent = subprocess_agent("headless", None);
5235 let mut def = echo_def("echo");
5236 def.stream_mode = Some("ndjson_lines".to_string());
5237 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5238 format: Some("json".to_string()),
5239 result_ptr: None,
5240 ok_from: None,
5241 stats: None,
5242 });
5243 let err = SubprocessProcessSpawnerFactory::build_embed(
5244 &agent,
5245 &serde_json::to_value(&def).unwrap(),
5246 None,
5247 )
5248 .unwrap_err();
5249 assert!(format!("{err}").contains("plain-mode"));
5250 }
5251
5252 #[test]
5253 fn build_embed_rejects_malformed_result_ptr_and_ok_from() {
5254 let agent = subprocess_agent("headless", None);
5255 let mut def = echo_def("echo");
5256 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5257 format: None,
5258 result_ptr: Some("result".to_string()),
5259 ok_from: None,
5260 stats: None,
5261 });
5262 let err = SubprocessProcessSpawnerFactory::build_embed(
5263 &agent,
5264 &serde_json::to_value(&def).unwrap(),
5265 None,
5266 )
5267 .unwrap_err();
5268 assert!(format!("{err}").contains("JSON Pointer"));
5269
5270 let mut def = echo_def("echo");
5271 def.output = Some(mlua_swarm_schema::SubprocessOutput {
5272 format: None,
5273 result_ptr: None,
5274 ok_from: Some("status".to_string()),
5275 stats: None,
5276 });
5277 let err = SubprocessProcessSpawnerFactory::build_embed(
5278 &agent,
5279 &serde_json::to_value(&def).unwrap(),
5280 None,
5281 )
5282 .unwrap_err();
5283 assert!(format!("{err}").contains("exit_code"));
5284 }
5285
5286 #[test]
5287 fn build_embed_bakes_profile_with_override_precedence() {
5288 let agent = subprocess_agent("headless", None);
5289 let def = echo_def("echo");
5290 let overrides = SubprocessOverrides {
5291 model: Some("override-model".to_string()),
5292 tools: vec!["Bash".to_string(), "Write".to_string()],
5293 cwd: Some("/tmp/override-wd".to_string()),
5294 };
5295 let sp = SubprocessProcessSpawnerFactory::build_embed(
5296 &agent,
5297 &serde_json::to_value(&def).unwrap(),
5298 Some(&serde_json::to_value(&overrides).unwrap()),
5299 )
5300 .expect("builds");
5301 let embed = sp.embed.as_ref().expect("embed template baked");
5302 assert_eq!(embed.model.as_deref(), Some("override-model"));
5303 assert_eq!(embed.tools_csv, "Bash,Write");
5304 assert_eq!(embed.cwd.as_deref(), Some("/tmp/override-wd"));
5305 assert_eq!(
5306 embed.system_prompt.as_deref(),
5307 Some("you are a headless worker")
5308 );
5309 }
5310}