1use crate::blueprint::compiler::{CompileError, Compiler};
23use crate::blueprint::{AuditDef, Blueprint, EngineDispatcher};
24use crate::core::agent_context::ContextPolicy;
25use crate::core::ctx::OperatorKind;
26use crate::core::engine::Engine;
27use crate::core::errors::EngineError;
28use crate::middleware::agent_context::AgentContextMiddleware;
29use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
30use crate::middleware::task_input::TaskInputMiddleware;
31use crate::middleware::worker_binding::WorkerBindingMiddleware;
32use crate::middleware::{AfterRunAuditMiddleware, SpawnerStack};
33use crate::operator::WorkerBinding;
34use crate::service::linker;
35use crate::store::run::RunContext;
36use crate::types::{CapToken, Role};
37use mlua_flow_ir::{Externs, NoExterns};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::sync::Arc;
42use std::time::Duration;
43use thiserror::Error;
44
45pub(crate) fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
72 blueprint
73 .agents
74 .iter()
75 .filter_map(|ad| {
76 let profile = ad.profile.as_ref()?;
77 let variant = profile.worker_binding.as_ref()?;
78 Some((
79 ad.name.clone(),
80 WorkerBinding {
81 variant: variant.clone(),
82 tools: profile.tools.clone(),
83 },
84 ))
85 })
86 .collect()
87}
88
89fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
98 blueprint.audits.clone()
99}
100
101pub(crate) fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
130 let global = blueprint.default_agent_ctx.clone();
131 let meta_pool = derive_step_metas(blueprint);
132 let per_agent = blueprint
133 .agents
134 .iter()
135 .filter_map(|ad| {
136 let meta = ad.meta.as_ref()?;
137 let inline = meta.ctx.clone();
138 let base = meta.meta_ref.as_ref().and_then(|name| {
139 let resolved = meta_pool.get(name).cloned();
140 if resolved.is_none() {
141 tracing::warn!(
142 agent = %ad.name,
143 meta_ref = %name,
144 "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
145 );
146 }
147 resolved
148 });
149 let merged = match (base, inline) {
150 (None, None) => None,
151 (Some(base), None) => Some(base),
152 (None, Some(inline)) => Some(inline),
153 (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
154 };
155 merged.map(|ctx| (ad.name.clone(), ctx))
156 })
157 .collect();
158 (global, per_agent)
159}
160
161pub(crate) fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
168 match (base, inline) {
169 (Value::Object(mut base), Value::Object(inline)) => {
170 for (k, v) in inline {
171 base.insert(k, v);
172 }
173 Value::Object(base)
174 }
175 (_, inline) => inline,
176 }
177}
178
179fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
187 blueprint
188 .metas
189 .iter()
190 .map(|m| (m.name.clone(), m.ctx.clone()))
191 .collect()
192}
193
194fn derive_context_policies(
202 blueprint: &Blueprint,
203) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
204 let default_policy = blueprint.default_context_policy.clone();
205 let per_agent = blueprint
206 .agents
207 .iter()
208 .filter_map(|ad| {
209 let meta = ad.meta.as_ref()?;
210 let policy = meta.context_policy.clone()?;
211 Some((ad.name.clone(), policy))
212 })
213 .collect();
214 (default_policy, per_agent)
215}
216
217fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
233 match (bp_default, task_init_ctx) {
234 (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
235 let mut merged = bp_map.clone();
236 for (k, v) in task_map {
237 merged.insert(k.clone(), v.clone());
238 }
239 Value::Object(merged)
240 }
241 (None, _) => task_init_ctx.clone(),
242 (_, task) => task.clone(),
243 }
244}
245
246pub fn merge_init_ctx_3layer(
262 bp_default: Option<&Value>,
263 task_init_ctx: &Value,
264 run_override: Option<&Value>,
265) -> Value {
266 let bp_task = merge_init_ctx(bp_default, task_init_ctx);
267 match run_override {
268 Some(run) => merge_init_ctx(Some(&bp_task), run),
269 None => bp_task,
270 }
271}
272
273fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
274 let mut out = HashMap::new();
275 if blueprint.operators.is_empty() {
276 return out;
277 }
278 for agent in &blueprint.agents {
279 let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
280 continue;
281 };
282 let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
283 continue;
284 };
285 if let Some(kind) = op_def.kind {
286 out.insert(agent.name.clone(), OperatorKind::from(kind));
287 }
288 }
289 out
290}
291
292#[derive(Debug, Error)]
294pub enum TaskLaunchError {
295 #[error("compile: {0}")]
297 Compile(#[from] CompileError),
298 #[error("engine: {0}")]
300 Engine(#[from] EngineError),
301 #[error("flow eval: {0}")]
304 FlowEval(String),
305}
306
307#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
330pub struct TaskInputSpec {
331 #[serde(default)]
333 pub project_root: Option<String>,
334 #[serde(default)]
336 pub work_dir: Option<String>,
337 #[serde(default)]
339 #[schemars(with = "Option<Value>")]
340 pub task_metadata: Option<Value>,
341}
342
343#[derive(Debug, Clone)]
345pub struct TaskLaunchInput {
346 pub blueprint: Blueprint,
348 pub operator_id: String,
350 pub role: Role,
352 pub ttl: Duration,
354 pub operator_kind: Option<OperatorKind>,
363 pub bridge_id: Option<String>,
367 pub hook_id: Option<String>,
370 pub operator_backend_id: Option<String>,
377 pub operator_kind_overrides: HashMap<String, OperatorKind>,
382 pub init_ctx: Value,
387 pub task_input: Option<TaskInputSpec>,
394 pub run_ctx: Option<RunContext>,
401}
402
403impl TaskLaunchInput {
404 pub fn automate(
414 blueprint: Blueprint,
415 operator_id: impl Into<String>,
416 role: Role,
417 ttl: Duration,
418 init_ctx: Value,
419 ) -> Self {
420 Self {
421 blueprint,
422 operator_id: operator_id.into(),
423 role,
424 ttl,
425 operator_kind: None,
426 bridge_id: None,
427 hook_id: None,
428 operator_backend_id: None,
429 operator_kind_overrides: HashMap::new(),
430 init_ctx,
431 task_input: None,
432 run_ctx: None,
433 }
434 }
435}
436
437#[derive(Debug, Clone)]
439pub struct TaskLaunchOutput {
440 pub token: CapToken,
442 pub final_ctx: Value,
446}
447
448pub struct TaskLaunchService {
452 engine: Engine,
453 compiler: Compiler,
454 externs: Arc<dyn Externs + Send + Sync>,
459}
460
461impl TaskLaunchService {
462 pub fn new(engine: Engine, compiler: Compiler) -> Self {
464 Self {
465 engine,
466 compiler,
467 externs: Arc::new(NoExterns),
468 }
469 }
470
471 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
475 self.externs = externs;
476 self
477 }
478
479 pub fn engine(&self) -> &Engine {
481 &self.engine
482 }
483
484 pub fn compiler(&self) -> &Compiler {
486 &self.compiler
487 }
488
489 pub async fn launch(
501 &self,
502 input: TaskLaunchInput,
503 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
504 let compiled = self.compiler.compile(&input.blueprint)?;
513 self.engine
522 .register_verdict_contracts(compiled.router.verdict_contracts.clone());
523 let spawner = linker::link(
524 compiled.router.clone(),
525 &input.blueprint.spawner_hints.layers,
526 &self.engine,
527 );
528 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
544 let (context_policy_default, context_policy_per_agent) =
545 derive_context_policies(&input.blueprint);
546 let spawner = SpawnerStack::new(spawner)
547 .layer(AgentContextMiddleware::new(
548 agent_ctx_global,
549 agent_ctx_per_agent,
550 context_policy_default,
551 context_policy_per_agent,
552 ))
553 .build();
554 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
561 SpawnerStack::new(spawner)
562 .layer(ProjectNameAliasMiddleware::new(alias))
563 .build()
564 } else {
565 spawner
566 };
567 let worker_bindings = derive_worker_bindings(&input.blueprint);
571 let spawner = if worker_bindings.is_empty() {
572 spawner
573 } else {
574 SpawnerStack::new(spawner)
575 .layer(WorkerBindingMiddleware::new(worker_bindings))
576 .build()
577 };
578 let audit_defs = derive_audits(&input.blueprint);
588 let spawner = if audit_defs.is_empty() {
589 spawner
590 } else {
591 SpawnerStack::new(spawner)
592 .layer(AfterRunAuditMiddleware::new(
593 audit_defs,
594 compiled.router.clone(),
595 ))
596 .build()
597 };
598
599 let spawner = match input.task_input.as_ref().and_then(|spec| {
606 TaskInputMiddleware::new_from_fields(
607 spec.project_root.clone(),
608 spec.work_dir.clone(),
609 spec.task_metadata.clone(),
610 )
611 }) {
612 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
613 None => spawner,
614 };
615
616 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
621 let bp_global_kind = input
622 .blueprint
623 .default_operator_kind
624 .map(OperatorKind::from);
625
626 let token = self
627 .engine
628 .attach_with_ids(
629 input.operator_id,
630 input.role,
631 input.ttl,
632 input.operator_kind,
633 input.bridge_id,
634 input.hook_id,
635 input.operator_backend_id,
636 input.operator_kind_overrides,
637 bp_agent_kinds,
638 bp_global_kind,
639 )
640 .await?;
641 let dispatcher =
642 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
643 let dispatcher = match input.run_ctx {
644 Some(run_ctx) => dispatcher.with_run(run_ctx),
645 None => dispatcher,
646 };
647 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
651 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
657 let dispatcher =
664 dispatcher.with_projection_placement(compiled.projection_placement.clone());
665 let merged_init_ctx =
671 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
672 let final_ctx = mlua_flow_ir::eval_async_externs(
673 &input.blueprint.flow,
674 merged_init_ctx,
675 &dispatcher,
676 &*self.externs,
677 )
678 .await
679 .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
680 Ok(TaskLaunchOutput { token, final_ctx })
681 }
682}
683
684#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
692 use crate::blueprint::{
693 current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
694 BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
695 };
696 use crate::core::config::EngineCfg;
697 use crate::worker::adapter::{WorkerError, WorkerResult};
698 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
699 use serde_json::json;
700 use std::sync::Arc;
701
702 fn path(s: &str) -> Expr {
703 Expr::Path {
704 at: s.parse().expect("literal test path"),
705 }
706 }
707 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
708 FlowNode::Step {
709 ref_: ref_.to_string(),
710 in_,
711 out,
712 }
713 }
714
715 fn agent(name: &str, fn_id: &str) -> AgentDef {
716 AgentDef {
717 name: name.to_string(),
718 kind: AgentKind::RustFn,
719 spec: json!({ "fn_id": fn_id }),
720 profile: None,
721 meta: Some(AgentMeta::default()),
722 runner: None,
723 runner_ref: None,
724 verdict: None,
725 }
726 }
727
728 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
729 let engine = Engine::new(EngineCfg::default());
730 let mut reg = SpawnerRegistry::new();
731 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
732 let compiler = Compiler::new(reg);
733 TaskLaunchService::new(engine, compiler)
734 }
735
736 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
737 Blueprint {
738 schema_version: current_schema_version(),
739 id: "ut".into(),
740 flow,
741 agents,
742 operators: vec![],
743 metas: vec![],
744 hints: CompilerHints::default(),
745 strategy: CompilerStrategy::default(),
746 metadata: BlueprintMetadata::default(),
747 spawner_hints: Default::default(),
748 default_agent_kind: AgentKind::Operator,
749 default_operator_kind: None,
750 default_init_ctx: None,
751 default_agent_ctx: None,
752 default_context_policy: None,
753 projection_placement: None,
754 audits: vec![],
755 degradation_policy: None,
756 runners: vec![],
757 default_runner: None,
758 }
759 }
760
761 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
762 TaskLaunchInput::automate(
763 blueprint,
764 "ut-op",
765 Role::Operator,
766 Duration::from_secs(30),
767 init_ctx,
768 )
769 }
770
771 #[test]
777 fn derive_audits_empty_by_default() {
778 let blueprint = bp(
779 step("echo", path("$.input"), path("$.out")),
780 vec![agent("echo", "echo")],
781 );
782 assert!(
783 derive_audits(&blueprint).is_empty(),
784 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
785 );
786 }
787
788 #[test]
789 fn derive_audits_returns_blueprint_audits_verbatim() {
790 let mut blueprint = bp(
791 step("echo", path("$.input"), path("$.out")),
792 vec![agent("echo", "echo")],
793 );
794 blueprint.audits = vec![crate::blueprint::AuditDef {
795 agent: "auditor".to_string(),
796 steps: None,
797 mode: crate::blueprint::AuditMode::Async,
798 }];
799 let got = derive_audits(&blueprint);
800 assert_eq!(got.len(), 1);
801 assert_eq!(got[0].agent, "auditor");
802 }
803
804 #[tokio::test]
805 async fn launch_appends_audit_artifact_when_audits_declared() {
806 use crate::blueprint::{AuditDef, AuditMode};
807
808 let factory = RustFnInProcessSpawnerFactory::new()
809 .register_fn("echo", |inv| async move {
810 Ok(WorkerResult {
811 value: json!({ "echoed": inv.prompt }),
812 ok: true,
813 })
814 })
815 .register_fn("audit-fn", |_inv| async move {
816 Ok(WorkerResult {
817 value: json!({ "finding": "clean" }),
818 ok: true,
819 })
820 });
821 let svc = build_service(factory);
822 let mut blueprint = bp(
823 step("echo", path("$.input"), path("$.out")),
824 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
825 );
826 blueprint.audits = vec![AuditDef {
827 agent: "auditor".to_string(),
828 steps: None,
829 mode: AuditMode::Sync,
830 }];
831 let out = svc
832 .launch(launch_input(blueprint, json!({ "input": "hi" })))
833 .await
834 .expect("launch ok — audits must never alter the audited step's outcome");
835 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
836
837 let audited_task_id = svc
838 .engine()
839 .with_state("test.find_audited_task", |s| {
840 s.tasks
841 .iter()
842 .find(|(_, t)| t.spec.agent == "echo")
843 .map(|(id, _)| id.clone())
844 })
845 .await
846 .expect("with_state")
847 .expect("the echo task must exist");
848 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
849 let found = tail.iter().any(|ev| {
850 matches!(
851 ev,
852 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
853 )
854 });
855 assert!(
856 found,
857 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
858 );
859 }
860
861 #[tokio::test]
862 async fn launch_single_step_writes_out_path() {
863 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
864 Ok(WorkerResult {
865 value: json!({ "echoed": inv.prompt }),
866 ok: true,
867 })
868 });
869 let svc = build_service(factory);
870 let blueprint = bp(
871 step("echo", path("$.input"), path("$.out")),
872 vec![agent("echo", "echo")],
873 );
874 let out = svc
875 .launch(launch_input(blueprint, json!({ "input": "hi" })))
876 .await
877 .expect("launch ok");
878 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
879 }
880
881 #[tokio::test]
882 async fn launch_three_step_seq_threads_ctx_forward() {
883 let factory = RustFnInProcessSpawnerFactory::new()
884 .register_fn("upper", |inv| async move {
885 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
886 Ok(WorkerResult {
887 value: json!(s.to_uppercase()),
888 ok: true,
889 })
890 })
891 .register_fn("suffix", |inv| async move {
892 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
893 Ok(WorkerResult {
894 value: json!(format!("{s}!")),
895 ok: true,
896 })
897 })
898 .register_fn("wrap", |inv| async move {
899 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
900 Ok(WorkerResult {
901 value: json!(format!("[{s}]")),
902 ok: true,
903 })
904 });
905 let svc = build_service(factory);
906 let flow = FlowNode::Seq {
907 children: vec![
908 step("upper", path("$.in"), path("$.s1")),
909 step("suffix", path("$.s1"), path("$.s2")),
910 step("wrap", path("$.s2"), path("$.s3")),
911 ],
912 };
913 let blueprint = bp(
914 flow,
915 vec![
916 agent("upper", "upper"),
917 agent("suffix", "suffix"),
918 agent("wrap", "wrap"),
919 ],
920 );
921 let out = svc
922 .launch(launch_input(blueprint, json!({ "in": "hello" })))
923 .await
924 .expect("launch ok");
925 assert_eq!(out.final_ctx["s1"], "HELLO");
926 assert_eq!(out.final_ctx["s2"], "HELLO!");
927 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
928 }
929
930 #[tokio::test]
931 async fn launch_fanout_join_all_parallel_completes() {
932 use std::sync::atomic::{AtomicU32, Ordering};
933 let counter = Arc::new(AtomicU32::new(0));
934 let max_seen = Arc::new(AtomicU32::new(0));
935 let counter_clone = counter.clone();
936 let max_clone = max_seen.clone();
937
938 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
941 let counter = counter_clone.clone();
942 let max_seen = max_clone.clone();
943 async move {
944 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
945 let mut prev = max_seen.load(Ordering::SeqCst);
946 while now > prev {
947 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
948 Ok(_) => break,
949 Err(p) => prev = p,
950 }
951 }
952 tokio::time::sleep(Duration::from_millis(50)).await;
953 counter.fetch_sub(1, Ordering::SeqCst);
954 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
955 Ok(WorkerResult {
956 value: json!(format!("did:{s}")),
957 ok: true,
958 })
959 }
960 });
961 let svc = build_service(factory);
962 let flow = FlowNode::Fanout {
963 items: path("$.items"),
964 bind: path("$.item"),
965 body: Box::new(step("para", path("$.item"), path("$.r"))),
966 join: JoinMode::All,
967 out: path("$.results"),
968 };
969 let blueprint = bp(flow, vec![agent("para", "para")]);
970 let out = svc
971 .launch(launch_input(
972 blueprint,
973 json!({ "items": ["a", "b", "c", "d"] }),
974 ))
975 .await
976 .expect("launch ok");
977 let results = out.final_ctx["results"].as_array().expect("array");
978 assert_eq!(results.len(), 4);
979 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
980 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
981 }
982 let max = max_seen.load(Ordering::SeqCst);
983 assert!(
984 max >= 2,
985 "expected parallel execution (max inflight >= 2), got {max}"
986 );
987 }
988
989 #[tokio::test]
990 async fn launch_propagates_worker_error_as_flow_eval_err() {
991 let factory = RustFnInProcessSpawnerFactory::new()
992 .register_fn("ok", |inv| async move {
993 Ok(WorkerResult {
994 value: json!(inv.prompt),
995 ok: true,
996 })
997 })
998 .register_fn("boom", |_inv| async move {
999 Err(WorkerError::Failed("intentional boom".into()))
1000 });
1001 let svc = build_service(factory);
1002 let flow = FlowNode::Seq {
1003 children: vec![
1004 step("ok", path("$.input"), path("$.s1")),
1005 step("boom", path("$.s1"), path("$.s2")),
1006 step("ok", path("$.s2"), path("$.s3")),
1007 ],
1008 };
1009 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1010 let err = svc
1011 .launch(launch_input(blueprint, json!({ "input": "x" })))
1012 .await
1013 .expect_err("expected fail");
1014 match err {
1015 TaskLaunchError::FlowEval(msg) => {
1016 assert!(
1017 msg.contains("boom") || msg.contains("intentional"),
1018 "expected error to mention worker failure, got: {msg}"
1019 );
1020 }
1021 other => panic!("expected FlowEval error, got {other:?}"),
1022 }
1023 }
1024
1025 #[tokio::test]
1026 async fn launch_resolves_call_extern_via_registered_externs() {
1027 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1028 Ok(WorkerResult {
1029 value: json!({ "echoed": inv.prompt }),
1030 ok: true,
1031 })
1032 });
1033 let mut externs = mlua_flow_ir::ExternMap::new();
1034 externs.register("fmt.greet", |args: &[Value]| {
1035 let name = args[0].as_str().unwrap_or("?");
1036 Ok(json!(format!("hello, {name}")))
1037 });
1038 let svc = build_service(factory).with_externs(Arc::new(externs));
1039 let flow = step(
1040 "echo",
1041 Expr::CallExtern {
1042 ref_: "fmt.greet".into(),
1043 args: vec![path("$.who")],
1044 },
1045 path("$.out"),
1046 );
1047 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1048 let out = svc
1049 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1050 .await
1051 .expect("launch ok");
1052 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1053 }
1054
1055 #[tokio::test]
1056 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1057 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1058 Ok(WorkerResult {
1059 value: json!(inv.prompt),
1060 ok: true,
1061 })
1062 });
1063 let svc = build_service(factory); let flow = step(
1065 "echo",
1066 Expr::CallExtern {
1067 ref_: "fmt.greet".into(),
1068 args: vec![],
1069 },
1070 path("$.out"),
1071 );
1072 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1073 let err = svc
1074 .launch(launch_input(blueprint, json!({})))
1075 .await
1076 .expect_err("expected fail");
1077 match err {
1078 TaskLaunchError::FlowEval(msg) => {
1079 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1080 }
1081 other => panic!("expected FlowEval error, got {other:?}"),
1082 }
1083 }
1084
1085 #[tokio::test]
1105 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1106 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1107 Ok(WorkerResult {
1108 value: json!(inv.prompt),
1109 ok: true,
1110 })
1111 });
1112 let svc = build_service(factory);
1113 let mut gate_agent = agent("gate", "gate");
1114 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1115 channel: mlua_swarm_schema::VerdictChannel::Body,
1116 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1117 });
1118 let flow = step("gate", path("$.input"), path("$.out"));
1119 let blueprint = bp(flow, vec![gate_agent]);
1120
1121 let out = svc
1122 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1123 .await
1124 .expect("launch ok");
1125 assert_eq!(out.final_ctx["out"], json!("PASS"));
1126
1127 let task_id = svc
1132 .engine()
1133 .with_state("test.find_dispatched_task_id", |s| {
1134 s.tasks.keys().next().cloned()
1135 })
1136 .await
1137 .expect("with_state")
1138 .expect("launch must have dispatched exactly one Step (one TaskState)");
1139
1140 let contract = svc
1141 .engine()
1142 .verdict_contract_for_task(&task_id)
1143 .await
1144 .expect(
1145 "TaskLaunchService::launch must have merged this Blueprint's compiled \
1146 verdict_contracts into the engine's runtime registry \
1147 (Engine::register_verdict_contracts, called right after \
1148 compiler.compile succeeds) — verdict_contract_for_task resolving None \
1149 here means that production wiring regressed",
1150 );
1151 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
1152 assert_eq!(
1153 contract.values,
1154 vec!["PASS".to_string(), "BLOCKED".to_string()]
1155 );
1156 }
1157
1158 #[tokio::test]
1163 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1164 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1165 use crate::types::{RunId, TaskId};
1166
1167 let factory = RustFnInProcessSpawnerFactory::new()
1168 .register_fn("upper", |inv| async move {
1169 Ok(WorkerResult {
1170 value: json!(inv.prompt.to_uppercase()),
1171 ok: true,
1172 })
1173 })
1174 .register_fn("suffix", |inv| async move {
1175 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1176 Ok(WorkerResult {
1177 value: json!(format!("{s}!")),
1178 ok: true,
1179 })
1180 });
1181 let svc = build_service(factory);
1182 let flow = FlowNode::Seq {
1183 children: vec![
1184 step("upper", path("$.in"), path("$.s1")),
1185 step("suffix", path("$.s1"), path("$.s2")),
1186 ],
1187 };
1188 let blueprint = bp(
1189 flow,
1190 vec![agent("upper", "upper"), agent("suffix", "suffix")],
1191 );
1192
1193 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1194 let run_id = RunId::new();
1195 run_store
1196 .create(RunRecord {
1197 id: run_id.clone(),
1198 task_id: TaskId::new(),
1199 status: RunStatus::Running,
1200 step_entries: Vec::new(),
1201 degradations: Vec::new(),
1202 operator_sid: None,
1203 result_ref: None,
1204 created_at: 0,
1205 updated_at: 0,
1206 })
1207 .await
1208 .expect("seed RunRecord");
1209
1210 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
1211 input.run_ctx = Some(RunContext {
1212 run_id: run_id.clone(),
1213 run_store: run_store.clone(),
1214 });
1215
1216 let out = svc.launch(input).await.expect("launch ok");
1217 assert_eq!(out.final_ctx["s2"], "HI!");
1218
1219 let run = run_store.get(&run_id).await.expect("run present");
1220 assert_eq!(
1221 run.step_entries.len(),
1222 2,
1223 "expected one step_entry per dispatched step, got {:?}",
1224 run.step_entries
1225 );
1226 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1227 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1228 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1229 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1230 }
1231
1232 #[tokio::test]
1233 async fn launch_without_run_ctx_appends_no_step_entries() {
1234 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1238 Ok(WorkerResult {
1239 value: json!(inv.prompt),
1240 ok: true,
1241 })
1242 });
1243 let svc = build_service(factory);
1244 let blueprint = bp(
1245 step("echo", path("$.input"), path("$.out")),
1246 vec![agent("echo", "echo")],
1247 );
1248 let input = launch_input(blueprint, json!({ "input": "hi" }));
1249 assert!(
1250 input.run_ctx.is_none(),
1251 "automate() defaults run_ctx to None"
1252 );
1253 let out = svc.launch(input).await.expect("launch ok");
1254 assert_eq!(out.final_ctx["out"], "hi");
1255 }
1256
1257 #[tokio::test]
1263 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1264 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1271 Ok(WorkerResult {
1272 value: json!({ "echoed": inv.prompt }),
1273 ok: true,
1274 })
1275 });
1276 let svc = build_service(factory);
1277 let blueprint = bp(
1278 step("echo", path("$.input"), path("$.out")),
1279 vec![agent("echo", "echo")],
1280 );
1281 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1282 input.task_input = Some(TaskInputSpec {
1283 project_root: Some("/repo".to_string()),
1284 work_dir: Some("/repo/work".to_string()),
1285 task_metadata: Some(json!({ "issue": 19 })),
1286 });
1287 let out = svc.launch(input).await.expect("launch ok");
1288 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1289 assert!(
1290 out.final_ctx.get("project_root").is_none(),
1291 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1292 out.final_ctx
1293 );
1294 assert!(out.final_ctx.get("work_dir").is_none());
1295 assert!(out.final_ctx.get("task_metadata").is_none());
1296 }
1297
1298 #[tokio::test]
1299 async fn launch_with_task_input_none_is_a_no_op() {
1300 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1301 Ok(WorkerResult {
1302 value: json!(inv.prompt),
1303 ok: true,
1304 })
1305 });
1306 let svc = build_service(factory);
1307 let blueprint = bp(
1308 step("echo", path("$.input"), path("$.out")),
1309 vec![agent("echo", "echo")],
1310 );
1311 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1312 assert!(input.task_input.is_none(), "automate() defaults to None");
1313 input.task_input = None;
1314 let out = svc.launch(input).await.expect("launch ok");
1315 assert_eq!(out.final_ctx["out"], "hi");
1316 }
1317
1318 #[tokio::test]
1319 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1320 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1324 Ok(WorkerResult {
1325 value: json!(inv.prompt),
1326 ok: true,
1327 })
1328 });
1329 let svc = build_service(factory);
1330 let blueprint = bp(
1331 step("echo", path("$.input"), path("$.out")),
1332 vec![agent("echo", "echo")],
1333 );
1334 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1335 input.task_input = Some(TaskInputSpec::default());
1336 let out = svc.launch(input).await.expect("launch ok");
1337 assert_eq!(out.final_ctx["out"], "hi");
1338 }
1339
1340 #[test]
1345 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1346 let bp_default = json!({ "seeded": "from-bp" });
1347 let task = json!({});
1348 let merged = merge_init_ctx(Some(&bp_default), &task);
1349 assert_eq!(merged, json!({ "seeded": "from-bp" }));
1350 }
1351
1352 #[test]
1353 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1354 let bp_default = json!({});
1355 let task = json!({ "seeded": "from-task" });
1356 let merged = merge_init_ctx(Some(&bp_default), &task);
1357 assert_eq!(merged, json!({ "seeded": "from-task" }));
1358 }
1359
1360 #[test]
1361 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1362 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1363 let task = json!({ "a": "task", "c": "task-only" });
1364 let merged = merge_init_ctx(Some(&bp_default), &task);
1365 assert_eq!(
1366 merged,
1367 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1368 );
1369 }
1370
1371 #[test]
1372 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1373 let bp_default = json!({ "seeded": "from-bp" });
1374 let task = json!("plain-string-seed");
1375 let merged = merge_init_ctx(Some(&bp_default), &task);
1376 assert_eq!(merged, json!("plain-string-seed"));
1377 }
1378
1379 #[test]
1380 fn merge_init_ctx_no_bp_default_is_a_no_op() {
1381 let task = json!({ "input": "hi" });
1382 let merged = merge_init_ctx(None, &task);
1383 assert_eq!(merged, task);
1384 }
1385
1386 #[test]
1391 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1392 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1396 let task = json!({ "a": "task", "c": "task-only" });
1397 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1398 let two_layer = merge_init_ctx(Some(&bp_default), &task);
1399 assert_eq!(three_layer, two_layer);
1400 assert_eq!(
1401 three_layer,
1402 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1403 );
1404 }
1405
1406 #[test]
1407 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1408 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1409 let task = json!({ "a": "task", "c": "task-only" });
1410 let run_override = json!({ "a": "run", "d": "run-only" });
1411 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1412 assert_eq!(
1413 merged,
1414 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1415 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1416 );
1417 }
1418
1419 #[test]
1420 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1421 let bp_default = json!({ "seeded": "from-bp" });
1422 let task = json!({ "seeded": "from-task" });
1423 let run_override = json!("plain-string-run-seed");
1424 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1425 assert_eq!(merged, json!("plain-string-run-seed"));
1426 }
1427
1428 #[test]
1429 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1430 let task = json!({ "input": "hi" });
1431 let merged = merge_init_ctx_3layer(None, &task, None);
1432 assert_eq!(merged, task);
1433 }
1434
1435 #[tokio::test]
1436 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1437 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1440 Ok(WorkerResult {
1441 value: json!(inv.prompt),
1442 ok: true,
1443 })
1444 });
1445 let svc = build_service(factory);
1446 let mut blueprint = bp(
1447 step("echo", path("$.greeting"), path("$.out")),
1448 vec![agent("echo", "echo")],
1449 );
1450 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1451 let out = svc
1453 .launch(launch_input(blueprint, json!({})))
1454 .await
1455 .expect("launch ok");
1456 assert_eq!(out.final_ctx["out"], "hello from bp");
1457 }
1458
1459 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1464 AgentDef {
1465 name: name.to_string(),
1466 kind: AgentKind::RustFn,
1467 spec: json!({ "fn_id": fn_id }),
1468 profile: None,
1469 meta: Some(meta),
1470 runner: None,
1471 runner_ref: None,
1472 verdict: None,
1473 }
1474 }
1475
1476 #[test]
1477 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1478 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1479 let (global, per_agent) = derive_agent_ctx(&blueprint);
1480 assert_eq!(global, None);
1481 assert!(per_agent.is_empty());
1482 }
1483
1484 #[test]
1485 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1486 let mut blueprint = bp(
1487 step("echo", path("$.in"), path("$.out")),
1488 vec![
1489 agent_with_meta(
1490 "with-ctx",
1491 "echo",
1492 AgentMeta {
1493 ctx: Some(json!({ "org_conventions": "x" })),
1494 ..Default::default()
1495 },
1496 ),
1497 agent("no-ctx", "echo"),
1498 ],
1499 );
1500 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1501 let (global, per_agent) = derive_agent_ctx(&blueprint);
1502 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1503 assert_eq!(
1504 per_agent.len(),
1505 1,
1506 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1507 );
1508 assert_eq!(
1509 per_agent.get("with-ctx"),
1510 Some(&json!({ "org_conventions": "x" }))
1511 );
1512 assert!(!per_agent.contains_key("no-ctx"));
1513 }
1514
1515 #[test]
1516 fn derive_context_policies_empty_blueprint_yields_empty_state() {
1517 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1518 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1519 assert_eq!(default_policy, None);
1520 assert!(per_agent.is_empty());
1521 }
1522
1523 #[test]
1524 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1525 let mut blueprint = bp(
1526 step("echo", path("$.in"), path("$.out")),
1527 vec![
1528 agent_with_meta(
1529 "with-policy",
1530 "echo",
1531 AgentMeta {
1532 context_policy: Some(ContextPolicy {
1533 include: None,
1534 exclude: vec!["work_dir".to_string()],
1535 ..Default::default()
1536 }),
1537 ..Default::default()
1538 },
1539 ),
1540 agent("no-policy", "echo"),
1541 ],
1542 );
1543 blueprint.default_context_policy = Some(ContextPolicy {
1544 include: Some(vec!["project_root".to_string()]),
1545 exclude: vec![],
1546 ..Default::default()
1547 });
1548 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1549 assert_eq!(
1550 default_policy,
1551 Some(ContextPolicy {
1552 include: Some(vec!["project_root".to_string()]),
1553 exclude: vec![],
1554 ..Default::default()
1555 })
1556 );
1557 assert_eq!(per_agent.len(), 1);
1558 assert_eq!(
1559 per_agent.get("with-policy"),
1560 Some(&ContextPolicy {
1561 include: None,
1562 exclude: vec!["work_dir".to_string()],
1563 ..Default::default()
1564 })
1565 );
1566 assert!(!per_agent.contains_key("no-policy"));
1567 }
1568
1569 #[test]
1575 fn derive_step_metas_empty_blueprint_yields_empty_map() {
1576 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1577 assert!(derive_step_metas(&blueprint).is_empty());
1578 }
1579
1580 #[test]
1581 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1582 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1583 blueprint.metas = vec![
1584 MetaDef {
1585 name: "heavy-scan".to_string(),
1586 ctx: json!({ "work_dir": "/x" }),
1587 },
1588 MetaDef {
1589 name: "light-scan".to_string(),
1590 ctx: json!({ "work_dir": "/y" }),
1591 },
1592 ];
1593 let metas = derive_step_metas(&blueprint);
1594 assert_eq!(metas.len(), 2);
1595 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1596 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1597 }
1598
1599 #[test]
1600 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1601 let mut blueprint = bp(
1602 step("echo", path("$.in"), path("$.out")),
1603 vec![agent_with_meta(
1604 "with-meta-ref",
1605 "echo",
1606 AgentMeta {
1607 ctx: Some(json!({ "work_dir": "/inline-wins" })),
1608 meta_ref: Some("shared".to_string()),
1609 ..Default::default()
1610 },
1611 )],
1612 );
1613 blueprint.metas = vec![MetaDef {
1614 name: "shared".to_string(),
1615 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1616 }];
1617 let (_, per_agent) = derive_agent_ctx(&blueprint);
1618 assert_eq!(
1619 per_agent.get("with-meta-ref"),
1620 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1621 "inline ctx must win the collided key while pool-only keys survive the merge"
1622 );
1623 }
1624
1625 #[test]
1626 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1627 let mut blueprint = bp(
1628 step("echo", path("$.in"), path("$.out")),
1629 vec![agent_with_meta(
1630 "with-meta-ref-only",
1631 "echo",
1632 AgentMeta {
1633 meta_ref: Some("shared".to_string()),
1634 ..Default::default()
1635 },
1636 )],
1637 );
1638 blueprint.metas = vec![MetaDef {
1639 name: "shared".to_string(),
1640 ctx: json!({ "work_dir": "/base" }),
1641 }];
1642 let (_, per_agent) = derive_agent_ctx(&blueprint);
1643 assert_eq!(
1644 per_agent.get("with-meta-ref-only"),
1645 Some(&json!({ "work_dir": "/base" }))
1646 );
1647 }
1648
1649 #[test]
1650 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1651 let blueprint = bp(
1652 step("echo", path("$.in"), path("$.out")),
1653 vec![agent_with_meta(
1654 "with-unresolved-meta-ref",
1655 "echo",
1656 AgentMeta {
1657 ctx: Some(json!({ "work_dir": "/inline-only" })),
1658 meta_ref: Some("missing".to_string()),
1659 ..Default::default()
1660 },
1661 )],
1662 );
1663 let (_, per_agent) = derive_agent_ctx(&blueprint);
1665 assert_eq!(
1666 per_agent.get("with-unresolved-meta-ref"),
1667 Some(&json!({ "work_dir": "/inline-only" })),
1668 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1669 );
1670 }
1671
1672 #[test]
1688 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
1689 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
1690 AgentDef {
1691 name: name.to_string(),
1692 kind: AgentKind::Operator,
1693 spec: json!({}),
1694 profile: Some(AgentProfile {
1695 worker_binding: Some(variant.to_string()),
1696 tools: tools.into_iter().map(str::to_string).collect(),
1697 ..Default::default()
1698 }),
1699 meta: None,
1700 runner: None,
1701 runner_ref: None,
1702 verdict: None,
1703 }
1704 }
1705
1706 let blueprint = bp(
1707 step("planner", path("$.in"), path("$.out")),
1708 vec![
1709 legacy_agent("planner", "mse-worker-planner", vec!["Read", "Grep"]),
1710 legacy_agent("coder", "mse-worker-coder", vec![]),
1711 agent("no-binding", "echo"),
1712 ],
1713 );
1714
1715 let derived = derive_worker_bindings(&blueprint);
1716
1717 for agent_def in &blueprint.agents {
1718 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
1719 match derived.get(&agent_def.name) {
1720 Some(binding) => {
1721 assert_eq!(
1722 resolved,
1723 Some(Runner::WsClaudeCode {
1724 variant: binding.variant.clone(),
1725 tools: binding.tools.clone(),
1726 }),
1727 "resolve_runner must synthesize the same WsClaudeCode Runner \
1728 derive_worker_bindings produces for agent '{}'",
1729 agent_def.name
1730 );
1731 }
1732 None => {
1733 assert_eq!(
1734 resolved, None,
1735 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
1736 must resolve to None too (no other tier declared)",
1737 agent_def.name
1738 );
1739 }
1740 }
1741 }
1742 }
1743}