1use crate::blueprint::compiler::{CompileError, Compiler};
23use crate::blueprint::{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::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
45fn 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_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
118 let global = blueprint.default_agent_ctx.clone();
119 let meta_pool = derive_step_metas(blueprint);
120 let per_agent = blueprint
121 .agents
122 .iter()
123 .filter_map(|ad| {
124 let meta = ad.meta.as_ref()?;
125 let inline = meta.ctx.clone();
126 let base = meta.meta_ref.as_ref().and_then(|name| {
127 let resolved = meta_pool.get(name).cloned();
128 if resolved.is_none() {
129 tracing::warn!(
130 agent = %ad.name,
131 meta_ref = %name,
132 "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
133 );
134 }
135 resolved
136 });
137 let merged = match (base, inline) {
138 (None, None) => None,
139 (Some(base), None) => Some(base),
140 (None, Some(inline)) => Some(inline),
141 (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
142 };
143 merged.map(|ctx| (ad.name.clone(), ctx))
144 })
145 .collect();
146 (global, per_agent)
147}
148
149fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
156 match (base, inline) {
157 (Value::Object(mut base), Value::Object(inline)) => {
158 for (k, v) in inline {
159 base.insert(k, v);
160 }
161 Value::Object(base)
162 }
163 (_, inline) => inline,
164 }
165}
166
167fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
175 blueprint
176 .metas
177 .iter()
178 .map(|m| (m.name.clone(), m.ctx.clone()))
179 .collect()
180}
181
182fn derive_context_policies(
190 blueprint: &Blueprint,
191) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
192 let default_policy = blueprint.default_context_policy.clone();
193 let per_agent = blueprint
194 .agents
195 .iter()
196 .filter_map(|ad| {
197 let meta = ad.meta.as_ref()?;
198 let policy = meta.context_policy.clone()?;
199 Some((ad.name.clone(), policy))
200 })
201 .collect();
202 (default_policy, per_agent)
203}
204
205fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
221 match (bp_default, task_init_ctx) {
222 (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
223 let mut merged = bp_map.clone();
224 for (k, v) in task_map {
225 merged.insert(k.clone(), v.clone());
226 }
227 Value::Object(merged)
228 }
229 (None, _) => task_init_ctx.clone(),
230 (_, task) => task.clone(),
231 }
232}
233
234pub fn merge_init_ctx_3layer(
250 bp_default: Option<&Value>,
251 task_init_ctx: &Value,
252 run_override: Option<&Value>,
253) -> Value {
254 let bp_task = merge_init_ctx(bp_default, task_init_ctx);
255 match run_override {
256 Some(run) => merge_init_ctx(Some(&bp_task), run),
257 None => bp_task,
258 }
259}
260
261fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
262 let mut out = HashMap::new();
263 if blueprint.operators.is_empty() {
264 return out;
265 }
266 for agent in &blueprint.agents {
267 let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
268 continue;
269 };
270 let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
271 continue;
272 };
273 if let Some(kind) = op_def.kind {
274 out.insert(agent.name.clone(), OperatorKind::from(kind));
275 }
276 }
277 out
278}
279
280#[derive(Debug, Error)]
282pub enum TaskLaunchError {
283 #[error("compile: {0}")]
285 Compile(#[from] CompileError),
286 #[error("engine: {0}")]
288 Engine(#[from] EngineError),
289 #[error("flow eval: {0}")]
292 FlowEval(String),
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
318pub struct TaskInputSpec {
319 #[serde(default)]
321 pub project_root: Option<String>,
322 #[serde(default)]
324 pub work_dir: Option<String>,
325 #[serde(default)]
327 #[schemars(with = "Option<Value>")]
328 pub task_metadata: Option<Value>,
329}
330
331#[derive(Debug, Clone)]
333pub struct TaskLaunchInput {
334 pub blueprint: Blueprint,
336 pub operator_id: String,
338 pub role: Role,
340 pub ttl: Duration,
342 pub operator_kind: Option<OperatorKind>,
351 pub bridge_id: Option<String>,
355 pub hook_id: Option<String>,
358 pub operator_backend_id: Option<String>,
365 pub operator_kind_overrides: HashMap<String, OperatorKind>,
370 pub init_ctx: Value,
375 pub task_input: Option<TaskInputSpec>,
382 pub run_ctx: Option<RunContext>,
389}
390
391impl TaskLaunchInput {
392 pub fn automate(
402 blueprint: Blueprint,
403 operator_id: impl Into<String>,
404 role: Role,
405 ttl: Duration,
406 init_ctx: Value,
407 ) -> Self {
408 Self {
409 blueprint,
410 operator_id: operator_id.into(),
411 role,
412 ttl,
413 operator_kind: None,
414 bridge_id: None,
415 hook_id: None,
416 operator_backend_id: None,
417 operator_kind_overrides: HashMap::new(),
418 init_ctx,
419 task_input: None,
420 run_ctx: None,
421 }
422 }
423}
424
425#[derive(Debug, Clone)]
427pub struct TaskLaunchOutput {
428 pub token: CapToken,
430 pub final_ctx: Value,
434}
435
436pub struct TaskLaunchService {
440 engine: Engine,
441 compiler: Compiler,
442 externs: Arc<dyn Externs + Send + Sync>,
447}
448
449impl TaskLaunchService {
450 pub fn new(engine: Engine, compiler: Compiler) -> Self {
452 Self {
453 engine,
454 compiler,
455 externs: Arc::new(NoExterns),
456 }
457 }
458
459 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
463 self.externs = externs;
464 self
465 }
466
467 pub fn engine(&self) -> &Engine {
469 &self.engine
470 }
471
472 pub fn compiler(&self) -> &Compiler {
474 &self.compiler
475 }
476
477 pub async fn launch(
489 &self,
490 input: TaskLaunchInput,
491 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
492 let compiled = self.compiler.compile(&input.blueprint)?;
501 let spawner = linker::link(
502 compiled.router.clone(),
503 &input.blueprint.spawner_hints.layers,
504 &self.engine,
505 );
506 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
522 let (context_policy_default, context_policy_per_agent) =
523 derive_context_policies(&input.blueprint);
524 let spawner = SpawnerStack::new(spawner)
525 .layer(AgentContextMiddleware::new(
526 agent_ctx_global,
527 agent_ctx_per_agent,
528 context_policy_default,
529 context_policy_per_agent,
530 ))
531 .build();
532 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
539 SpawnerStack::new(spawner)
540 .layer(ProjectNameAliasMiddleware::new(alias))
541 .build()
542 } else {
543 spawner
544 };
545 let worker_bindings = derive_worker_bindings(&input.blueprint);
549 let spawner = if worker_bindings.is_empty() {
550 spawner
551 } else {
552 SpawnerStack::new(spawner)
553 .layer(WorkerBindingMiddleware::new(worker_bindings))
554 .build()
555 };
556
557 let spawner = match input.task_input.as_ref().and_then(|spec| {
564 TaskInputMiddleware::new_from_fields(
565 spec.project_root.clone(),
566 spec.work_dir.clone(),
567 spec.task_metadata.clone(),
568 )
569 }) {
570 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
571 None => spawner,
572 };
573
574 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
579 let bp_global_kind = input
580 .blueprint
581 .default_operator_kind
582 .map(OperatorKind::from);
583
584 let token = self
585 .engine
586 .attach_with_ids(
587 input.operator_id,
588 input.role,
589 input.ttl,
590 input.operator_kind,
591 input.bridge_id,
592 input.hook_id,
593 input.operator_backend_id,
594 input.operator_kind_overrides,
595 bp_agent_kinds,
596 bp_global_kind,
597 )
598 .await?;
599 let dispatcher =
600 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
601 let dispatcher = match input.run_ctx {
602 Some(run_ctx) => dispatcher.with_run(run_ctx),
603 None => dispatcher,
604 };
605 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
609 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
615 let dispatcher =
622 dispatcher.with_projection_placement(compiled.projection_placement.clone());
623 let merged_init_ctx =
629 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
630 let final_ctx = mlua_flow_ir::eval_async_externs(
631 &input.blueprint.flow,
632 merged_init_ctx,
633 &dispatcher,
634 &*self.externs,
635 )
636 .await
637 .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
638 Ok(TaskLaunchOutput { token, final_ctx })
639 }
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
650 use crate::blueprint::{
651 current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
652 CompilerStrategy, MetaDef,
653 };
654 use crate::core::config::EngineCfg;
655 use crate::worker::adapter::{WorkerError, WorkerResult};
656 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
657 use serde_json::json;
658 use std::sync::Arc;
659
660 fn path(s: &str) -> Expr {
661 Expr::Path { at: s.to_string() }
662 }
663 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
664 FlowNode::Step {
665 ref_: ref_.to_string(),
666 in_,
667 out,
668 }
669 }
670
671 fn agent(name: &str, fn_id: &str) -> AgentDef {
672 AgentDef {
673 name: name.to_string(),
674 kind: AgentKind::RustFn,
675 spec: json!({ "fn_id": fn_id }),
676 profile: None,
677 meta: Some(AgentMeta::default()),
678 }
679 }
680
681 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
682 let engine = Engine::new(EngineCfg::default());
683 let mut reg = SpawnerRegistry::new();
684 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
685 let compiler = Compiler::new(reg);
686 TaskLaunchService::new(engine, compiler)
687 }
688
689 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
690 Blueprint {
691 schema_version: current_schema_version(),
692 id: "ut".into(),
693 flow,
694 agents,
695 operators: vec![],
696 metas: vec![],
697 hints: CompilerHints::default(),
698 strategy: CompilerStrategy::default(),
699 metadata: BlueprintMetadata::default(),
700 spawner_hints: Default::default(),
701 default_agent_kind: AgentKind::Operator,
702 default_operator_kind: None,
703 default_init_ctx: None,
704 default_agent_ctx: None,
705 default_context_policy: None,
706 projection_placement: None,
707 }
708 }
709
710 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
711 TaskLaunchInput::automate(
712 blueprint,
713 "ut-op",
714 Role::Operator,
715 Duration::from_secs(30),
716 init_ctx,
717 )
718 }
719
720 #[tokio::test]
721 async fn launch_single_step_writes_out_path() {
722 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
723 Ok(WorkerResult {
724 value: json!({ "echoed": inv.prompt }),
725 ok: true,
726 })
727 });
728 let svc = build_service(factory);
729 let blueprint = bp(
730 step("echo", path("$.input"), path("$.out")),
731 vec![agent("echo", "echo")],
732 );
733 let out = svc
734 .launch(launch_input(blueprint, json!({ "input": "hi" })))
735 .await
736 .expect("launch ok");
737 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
738 }
739
740 #[tokio::test]
741 async fn launch_three_step_seq_threads_ctx_forward() {
742 let factory = RustFnInProcessSpawnerFactory::new()
743 .register_fn("upper", |inv| async move {
744 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
745 Ok(WorkerResult {
746 value: json!(s.to_uppercase()),
747 ok: true,
748 })
749 })
750 .register_fn("suffix", |inv| async move {
751 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
752 Ok(WorkerResult {
753 value: json!(format!("{s}!")),
754 ok: true,
755 })
756 })
757 .register_fn("wrap", |inv| async move {
758 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
759 Ok(WorkerResult {
760 value: json!(format!("[{s}]")),
761 ok: true,
762 })
763 });
764 let svc = build_service(factory);
765 let flow = FlowNode::Seq {
766 children: vec![
767 step("upper", path("$.in"), path("$.s1")),
768 step("suffix", path("$.s1"), path("$.s2")),
769 step("wrap", path("$.s2"), path("$.s3")),
770 ],
771 };
772 let blueprint = bp(
773 flow,
774 vec![
775 agent("upper", "upper"),
776 agent("suffix", "suffix"),
777 agent("wrap", "wrap"),
778 ],
779 );
780 let out = svc
781 .launch(launch_input(blueprint, json!({ "in": "hello" })))
782 .await
783 .expect("launch ok");
784 assert_eq!(out.final_ctx["s1"], "HELLO");
785 assert_eq!(out.final_ctx["s2"], "HELLO!");
786 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
787 }
788
789 #[tokio::test]
790 async fn launch_fanout_join_all_parallel_completes() {
791 use std::sync::atomic::{AtomicU32, Ordering};
792 let counter = Arc::new(AtomicU32::new(0));
793 let max_seen = Arc::new(AtomicU32::new(0));
794 let counter_clone = counter.clone();
795 let max_clone = max_seen.clone();
796
797 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
800 let counter = counter_clone.clone();
801 let max_seen = max_clone.clone();
802 async move {
803 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
804 let mut prev = max_seen.load(Ordering::SeqCst);
805 while now > prev {
806 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
807 Ok(_) => break,
808 Err(p) => prev = p,
809 }
810 }
811 tokio::time::sleep(Duration::from_millis(50)).await;
812 counter.fetch_sub(1, Ordering::SeqCst);
813 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
814 Ok(WorkerResult {
815 value: json!(format!("did:{s}")),
816 ok: true,
817 })
818 }
819 });
820 let svc = build_service(factory);
821 let flow = FlowNode::Fanout {
822 items: path("$.items"),
823 bind: path("$.item"),
824 body: Box::new(step("para", path("$.item"), path("$.r"))),
825 join: JoinMode::All,
826 out: path("$.results"),
827 };
828 let blueprint = bp(flow, vec![agent("para", "para")]);
829 let out = svc
830 .launch(launch_input(
831 blueprint,
832 json!({ "items": ["a", "b", "c", "d"] }),
833 ))
834 .await
835 .expect("launch ok");
836 let results = out.final_ctx["results"].as_array().expect("array");
837 assert_eq!(results.len(), 4);
838 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
839 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
840 }
841 let max = max_seen.load(Ordering::SeqCst);
842 assert!(
843 max >= 2,
844 "expected parallel execution (max inflight >= 2), got {max}"
845 );
846 }
847
848 #[tokio::test]
849 async fn launch_propagates_worker_error_as_flow_eval_err() {
850 let factory = RustFnInProcessSpawnerFactory::new()
851 .register_fn("ok", |inv| async move {
852 Ok(WorkerResult {
853 value: json!(inv.prompt),
854 ok: true,
855 })
856 })
857 .register_fn("boom", |_inv| async move {
858 Err(WorkerError::Failed("intentional boom".into()))
859 });
860 let svc = build_service(factory);
861 let flow = FlowNode::Seq {
862 children: vec![
863 step("ok", path("$.input"), path("$.s1")),
864 step("boom", path("$.s1"), path("$.s2")),
865 step("ok", path("$.s2"), path("$.s3")),
866 ],
867 };
868 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
869 let err = svc
870 .launch(launch_input(blueprint, json!({ "input": "x" })))
871 .await
872 .expect_err("expected fail");
873 match err {
874 TaskLaunchError::FlowEval(msg) => {
875 assert!(
876 msg.contains("boom") || msg.contains("intentional"),
877 "expected error to mention worker failure, got: {msg}"
878 );
879 }
880 other => panic!("expected FlowEval error, got {other:?}"),
881 }
882 }
883
884 #[tokio::test]
885 async fn launch_resolves_call_extern_via_registered_externs() {
886 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
887 Ok(WorkerResult {
888 value: json!({ "echoed": inv.prompt }),
889 ok: true,
890 })
891 });
892 let mut externs = mlua_flow_ir::ExternMap::new();
893 externs.register("fmt.greet", |args: &[Value]| {
894 let name = args[0].as_str().unwrap_or("?");
895 Ok(json!(format!("hello, {name}")))
896 });
897 let svc = build_service(factory).with_externs(Arc::new(externs));
898 let flow = step(
899 "echo",
900 Expr::CallExtern {
901 ref_: "fmt.greet".into(),
902 args: vec![path("$.who")],
903 },
904 path("$.out"),
905 );
906 let blueprint = bp(flow, vec![agent("echo", "echo")]);
907 let out = svc
908 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
909 .await
910 .expect("launch ok");
911 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
912 }
913
914 #[tokio::test]
915 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
916 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
917 Ok(WorkerResult {
918 value: json!(inv.prompt),
919 ok: true,
920 })
921 });
922 let svc = build_service(factory); let flow = step(
924 "echo",
925 Expr::CallExtern {
926 ref_: "fmt.greet".into(),
927 args: vec![],
928 },
929 path("$.out"),
930 );
931 let blueprint = bp(flow, vec![agent("echo", "echo")]);
932 let err = svc
933 .launch(launch_input(blueprint, json!({})))
934 .await
935 .expect_err("expected fail");
936 match err {
937 TaskLaunchError::FlowEval(msg) => {
938 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
939 }
940 other => panic!("expected FlowEval error, got {other:?}"),
941 }
942 }
943
944 #[tokio::test]
949 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
950 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
951 use crate::types::{RunId, TaskId};
952
953 let factory = RustFnInProcessSpawnerFactory::new()
954 .register_fn("upper", |inv| async move {
955 Ok(WorkerResult {
956 value: json!(inv.prompt.to_uppercase()),
957 ok: true,
958 })
959 })
960 .register_fn("suffix", |inv| async move {
961 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
962 Ok(WorkerResult {
963 value: json!(format!("{s}!")),
964 ok: true,
965 })
966 });
967 let svc = build_service(factory);
968 let flow = FlowNode::Seq {
969 children: vec![
970 step("upper", path("$.in"), path("$.s1")),
971 step("suffix", path("$.s1"), path("$.s2")),
972 ],
973 };
974 let blueprint = bp(
975 flow,
976 vec![agent("upper", "upper"), agent("suffix", "suffix")],
977 );
978
979 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
980 let run_id = RunId::new();
981 run_store
982 .create(RunRecord {
983 id: run_id.clone(),
984 task_id: TaskId::new(),
985 status: RunStatus::Running,
986 step_entries: Vec::new(),
987 operator_sid: None,
988 result_ref: None,
989 created_at: 0,
990 updated_at: 0,
991 })
992 .await
993 .expect("seed RunRecord");
994
995 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
996 input.run_ctx = Some(RunContext {
997 run_id: run_id.clone(),
998 run_store: run_store.clone(),
999 });
1000
1001 let out = svc.launch(input).await.expect("launch ok");
1002 assert_eq!(out.final_ctx["s2"], "HI!");
1003
1004 let run = run_store.get(&run_id).await.expect("run present");
1005 assert_eq!(
1006 run.step_entries.len(),
1007 2,
1008 "expected one step_entry per dispatched step, got {:?}",
1009 run.step_entries
1010 );
1011 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1012 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1013 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1014 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1015 }
1016
1017 #[tokio::test]
1018 async fn launch_without_run_ctx_appends_no_step_entries() {
1019 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1023 Ok(WorkerResult {
1024 value: json!(inv.prompt),
1025 ok: true,
1026 })
1027 });
1028 let svc = build_service(factory);
1029 let blueprint = bp(
1030 step("echo", path("$.input"), path("$.out")),
1031 vec![agent("echo", "echo")],
1032 );
1033 let input = launch_input(blueprint, json!({ "input": "hi" }));
1034 assert!(
1035 input.run_ctx.is_none(),
1036 "automate() defaults run_ctx to None"
1037 );
1038 let out = svc.launch(input).await.expect("launch ok");
1039 assert_eq!(out.final_ctx["out"], "hi");
1040 }
1041
1042 #[tokio::test]
1048 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1049 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1056 Ok(WorkerResult {
1057 value: json!({ "echoed": inv.prompt }),
1058 ok: true,
1059 })
1060 });
1061 let svc = build_service(factory);
1062 let blueprint = bp(
1063 step("echo", path("$.input"), path("$.out")),
1064 vec![agent("echo", "echo")],
1065 );
1066 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1067 input.task_input = Some(TaskInputSpec {
1068 project_root: Some("/repo".to_string()),
1069 work_dir: Some("/repo/work".to_string()),
1070 task_metadata: Some(json!({ "issue": 19 })),
1071 });
1072 let out = svc.launch(input).await.expect("launch ok");
1073 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1074 assert!(
1075 out.final_ctx.get("project_root").is_none(),
1076 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1077 out.final_ctx
1078 );
1079 assert!(out.final_ctx.get("work_dir").is_none());
1080 assert!(out.final_ctx.get("task_metadata").is_none());
1081 }
1082
1083 #[tokio::test]
1084 async fn launch_with_task_input_none_is_a_no_op() {
1085 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1086 Ok(WorkerResult {
1087 value: json!(inv.prompt),
1088 ok: true,
1089 })
1090 });
1091 let svc = build_service(factory);
1092 let blueprint = bp(
1093 step("echo", path("$.input"), path("$.out")),
1094 vec![agent("echo", "echo")],
1095 );
1096 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1097 assert!(input.task_input.is_none(), "automate() defaults to None");
1098 input.task_input = None;
1099 let out = svc.launch(input).await.expect("launch ok");
1100 assert_eq!(out.final_ctx["out"], "hi");
1101 }
1102
1103 #[tokio::test]
1104 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1105 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1109 Ok(WorkerResult {
1110 value: json!(inv.prompt),
1111 ok: true,
1112 })
1113 });
1114 let svc = build_service(factory);
1115 let blueprint = bp(
1116 step("echo", path("$.input"), path("$.out")),
1117 vec![agent("echo", "echo")],
1118 );
1119 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1120 input.task_input = Some(TaskInputSpec::default());
1121 let out = svc.launch(input).await.expect("launch ok");
1122 assert_eq!(out.final_ctx["out"], "hi");
1123 }
1124
1125 #[test]
1130 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1131 let bp_default = json!({ "seeded": "from-bp" });
1132 let task = json!({});
1133 let merged = merge_init_ctx(Some(&bp_default), &task);
1134 assert_eq!(merged, json!({ "seeded": "from-bp" }));
1135 }
1136
1137 #[test]
1138 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1139 let bp_default = json!({});
1140 let task = json!({ "seeded": "from-task" });
1141 let merged = merge_init_ctx(Some(&bp_default), &task);
1142 assert_eq!(merged, json!({ "seeded": "from-task" }));
1143 }
1144
1145 #[test]
1146 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1147 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1148 let task = json!({ "a": "task", "c": "task-only" });
1149 let merged = merge_init_ctx(Some(&bp_default), &task);
1150 assert_eq!(
1151 merged,
1152 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1153 );
1154 }
1155
1156 #[test]
1157 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1158 let bp_default = json!({ "seeded": "from-bp" });
1159 let task = json!("plain-string-seed");
1160 let merged = merge_init_ctx(Some(&bp_default), &task);
1161 assert_eq!(merged, json!("plain-string-seed"));
1162 }
1163
1164 #[test]
1165 fn merge_init_ctx_no_bp_default_is_a_no_op() {
1166 let task = json!({ "input": "hi" });
1167 let merged = merge_init_ctx(None, &task);
1168 assert_eq!(merged, task);
1169 }
1170
1171 #[test]
1176 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1177 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1181 let task = json!({ "a": "task", "c": "task-only" });
1182 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1183 let two_layer = merge_init_ctx(Some(&bp_default), &task);
1184 assert_eq!(three_layer, two_layer);
1185 assert_eq!(
1186 three_layer,
1187 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1188 );
1189 }
1190
1191 #[test]
1192 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1193 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1194 let task = json!({ "a": "task", "c": "task-only" });
1195 let run_override = json!({ "a": "run", "d": "run-only" });
1196 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1197 assert_eq!(
1198 merged,
1199 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1200 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1201 );
1202 }
1203
1204 #[test]
1205 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1206 let bp_default = json!({ "seeded": "from-bp" });
1207 let task = json!({ "seeded": "from-task" });
1208 let run_override = json!("plain-string-run-seed");
1209 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1210 assert_eq!(merged, json!("plain-string-run-seed"));
1211 }
1212
1213 #[test]
1214 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1215 let task = json!({ "input": "hi" });
1216 let merged = merge_init_ctx_3layer(None, &task, None);
1217 assert_eq!(merged, task);
1218 }
1219
1220 #[tokio::test]
1221 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1222 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1225 Ok(WorkerResult {
1226 value: json!(inv.prompt),
1227 ok: true,
1228 })
1229 });
1230 let svc = build_service(factory);
1231 let mut blueprint = bp(
1232 step("echo", path("$.greeting"), path("$.out")),
1233 vec![agent("echo", "echo")],
1234 );
1235 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1236 let out = svc
1238 .launch(launch_input(blueprint, json!({})))
1239 .await
1240 .expect("launch ok");
1241 assert_eq!(out.final_ctx["out"], "hello from bp");
1242 }
1243
1244 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1249 AgentDef {
1250 name: name.to_string(),
1251 kind: AgentKind::RustFn,
1252 spec: json!({ "fn_id": fn_id }),
1253 profile: None,
1254 meta: Some(meta),
1255 }
1256 }
1257
1258 #[test]
1259 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1260 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1261 let (global, per_agent) = derive_agent_ctx(&blueprint);
1262 assert_eq!(global, None);
1263 assert!(per_agent.is_empty());
1264 }
1265
1266 #[test]
1267 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1268 let mut blueprint = bp(
1269 step("echo", path("$.in"), path("$.out")),
1270 vec![
1271 agent_with_meta(
1272 "with-ctx",
1273 "echo",
1274 AgentMeta {
1275 ctx: Some(json!({ "org_conventions": "x" })),
1276 ..Default::default()
1277 },
1278 ),
1279 agent("no-ctx", "echo"),
1280 ],
1281 );
1282 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1283 let (global, per_agent) = derive_agent_ctx(&blueprint);
1284 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1285 assert_eq!(
1286 per_agent.len(),
1287 1,
1288 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1289 );
1290 assert_eq!(
1291 per_agent.get("with-ctx"),
1292 Some(&json!({ "org_conventions": "x" }))
1293 );
1294 assert!(!per_agent.contains_key("no-ctx"));
1295 }
1296
1297 #[test]
1298 fn derive_context_policies_empty_blueprint_yields_empty_state() {
1299 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1300 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1301 assert_eq!(default_policy, None);
1302 assert!(per_agent.is_empty());
1303 }
1304
1305 #[test]
1306 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1307 let mut blueprint = bp(
1308 step("echo", path("$.in"), path("$.out")),
1309 vec![
1310 agent_with_meta(
1311 "with-policy",
1312 "echo",
1313 AgentMeta {
1314 context_policy: Some(ContextPolicy {
1315 include: None,
1316 exclude: vec!["work_dir".to_string()],
1317 ..Default::default()
1318 }),
1319 ..Default::default()
1320 },
1321 ),
1322 agent("no-policy", "echo"),
1323 ],
1324 );
1325 blueprint.default_context_policy = Some(ContextPolicy {
1326 include: Some(vec!["project_root".to_string()]),
1327 exclude: vec![],
1328 ..Default::default()
1329 });
1330 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1331 assert_eq!(
1332 default_policy,
1333 Some(ContextPolicy {
1334 include: Some(vec!["project_root".to_string()]),
1335 exclude: vec![],
1336 ..Default::default()
1337 })
1338 );
1339 assert_eq!(per_agent.len(), 1);
1340 assert_eq!(
1341 per_agent.get("with-policy"),
1342 Some(&ContextPolicy {
1343 include: None,
1344 exclude: vec!["work_dir".to_string()],
1345 ..Default::default()
1346 })
1347 );
1348 assert!(!per_agent.contains_key("no-policy"));
1349 }
1350
1351 #[test]
1357 fn derive_step_metas_empty_blueprint_yields_empty_map() {
1358 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1359 assert!(derive_step_metas(&blueprint).is_empty());
1360 }
1361
1362 #[test]
1363 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1364 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1365 blueprint.metas = vec![
1366 MetaDef {
1367 name: "heavy-scan".to_string(),
1368 ctx: json!({ "work_dir": "/x" }),
1369 },
1370 MetaDef {
1371 name: "light-scan".to_string(),
1372 ctx: json!({ "work_dir": "/y" }),
1373 },
1374 ];
1375 let metas = derive_step_metas(&blueprint);
1376 assert_eq!(metas.len(), 2);
1377 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1378 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1379 }
1380
1381 #[test]
1382 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1383 let mut blueprint = bp(
1384 step("echo", path("$.in"), path("$.out")),
1385 vec![agent_with_meta(
1386 "with-meta-ref",
1387 "echo",
1388 AgentMeta {
1389 ctx: Some(json!({ "work_dir": "/inline-wins" })),
1390 meta_ref: Some("shared".to_string()),
1391 ..Default::default()
1392 },
1393 )],
1394 );
1395 blueprint.metas = vec![MetaDef {
1396 name: "shared".to_string(),
1397 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1398 }];
1399 let (_, per_agent) = derive_agent_ctx(&blueprint);
1400 assert_eq!(
1401 per_agent.get("with-meta-ref"),
1402 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1403 "inline ctx must win the collided key while pool-only keys survive the merge"
1404 );
1405 }
1406
1407 #[test]
1408 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1409 let mut blueprint = bp(
1410 step("echo", path("$.in"), path("$.out")),
1411 vec![agent_with_meta(
1412 "with-meta-ref-only",
1413 "echo",
1414 AgentMeta {
1415 meta_ref: Some("shared".to_string()),
1416 ..Default::default()
1417 },
1418 )],
1419 );
1420 blueprint.metas = vec![MetaDef {
1421 name: "shared".to_string(),
1422 ctx: json!({ "work_dir": "/base" }),
1423 }];
1424 let (_, per_agent) = derive_agent_ctx(&blueprint);
1425 assert_eq!(
1426 per_agent.get("with-meta-ref-only"),
1427 Some(&json!({ "work_dir": "/base" }))
1428 );
1429 }
1430
1431 #[test]
1432 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1433 let blueprint = bp(
1434 step("echo", path("$.in"), path("$.out")),
1435 vec![agent_with_meta(
1436 "with-unresolved-meta-ref",
1437 "echo",
1438 AgentMeta {
1439 ctx: Some(json!({ "work_dir": "/inline-only" })),
1440 meta_ref: Some("missing".to_string()),
1441 ..Default::default()
1442 },
1443 )],
1444 );
1445 let (_, per_agent) = derive_agent_ctx(&blueprint);
1447 assert_eq!(
1448 per_agent.get("with-unresolved-meta-ref"),
1449 Some(&json!({ "work_dir": "/inline-only" })),
1450 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1451 );
1452 }
1453}