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 merged_init_ctx =
615 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
616 let final_ctx = mlua_flow_ir::eval_async_externs(
617 &input.blueprint.flow,
618 merged_init_ctx,
619 &dispatcher,
620 &*self.externs,
621 )
622 .await
623 .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
624 Ok(TaskLaunchOutput { token, final_ctx })
625 }
626}
627
628#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
636 use crate::blueprint::{
637 current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
638 CompilerStrategy, MetaDef,
639 };
640 use crate::core::config::EngineCfg;
641 use crate::worker::adapter::{WorkerError, WorkerResult};
642 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
643 use serde_json::json;
644 use std::sync::Arc;
645
646 fn path(s: &str) -> Expr {
647 Expr::Path { at: s.to_string() }
648 }
649 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
650 FlowNode::Step {
651 ref_: ref_.to_string(),
652 in_,
653 out,
654 }
655 }
656
657 fn agent(name: &str, fn_id: &str) -> AgentDef {
658 AgentDef {
659 name: name.to_string(),
660 kind: AgentKind::RustFn,
661 spec: json!({ "fn_id": fn_id }),
662 profile: None,
663 meta: Some(AgentMeta::default()),
664 }
665 }
666
667 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
668 let engine = Engine::new(EngineCfg::default());
669 let mut reg = SpawnerRegistry::new();
670 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
671 let compiler = Compiler::new(reg);
672 TaskLaunchService::new(engine, compiler)
673 }
674
675 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
676 Blueprint {
677 schema_version: current_schema_version(),
678 id: "ut".into(),
679 flow,
680 agents,
681 operators: vec![],
682 metas: vec![],
683 hints: CompilerHints::default(),
684 strategy: CompilerStrategy::default(),
685 metadata: BlueprintMetadata::default(),
686 spawner_hints: Default::default(),
687 default_agent_kind: AgentKind::Operator,
688 default_operator_kind: None,
689 default_init_ctx: None,
690 default_agent_ctx: None,
691 default_context_policy: None,
692 }
693 }
694
695 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
696 TaskLaunchInput::automate(
697 blueprint,
698 "ut-op",
699 Role::Operator,
700 Duration::from_secs(30),
701 init_ctx,
702 )
703 }
704
705 #[tokio::test]
706 async fn launch_single_step_writes_out_path() {
707 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
708 Ok(WorkerResult {
709 value: json!({ "echoed": inv.prompt }),
710 ok: true,
711 })
712 });
713 let svc = build_service(factory);
714 let blueprint = bp(
715 step("echo", path("$.input"), path("$.out")),
716 vec![agent("echo", "echo")],
717 );
718 let out = svc
719 .launch(launch_input(blueprint, json!({ "input": "hi" })))
720 .await
721 .expect("launch ok");
722 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
723 }
724
725 #[tokio::test]
726 async fn launch_three_step_seq_threads_ctx_forward() {
727 let factory = RustFnInProcessSpawnerFactory::new()
728 .register_fn("upper", |inv| async move {
729 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
730 Ok(WorkerResult {
731 value: json!(s.to_uppercase()),
732 ok: true,
733 })
734 })
735 .register_fn("suffix", |inv| async move {
736 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
737 Ok(WorkerResult {
738 value: json!(format!("{s}!")),
739 ok: true,
740 })
741 })
742 .register_fn("wrap", |inv| async move {
743 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
744 Ok(WorkerResult {
745 value: json!(format!("[{s}]")),
746 ok: true,
747 })
748 });
749 let svc = build_service(factory);
750 let flow = FlowNode::Seq {
751 children: vec![
752 step("upper", path("$.in"), path("$.s1")),
753 step("suffix", path("$.s1"), path("$.s2")),
754 step("wrap", path("$.s2"), path("$.s3")),
755 ],
756 };
757 let blueprint = bp(
758 flow,
759 vec![
760 agent("upper", "upper"),
761 agent("suffix", "suffix"),
762 agent("wrap", "wrap"),
763 ],
764 );
765 let out = svc
766 .launch(launch_input(blueprint, json!({ "in": "hello" })))
767 .await
768 .expect("launch ok");
769 assert_eq!(out.final_ctx["s1"], "HELLO");
770 assert_eq!(out.final_ctx["s2"], "HELLO!");
771 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
772 }
773
774 #[tokio::test]
775 async fn launch_fanout_join_all_parallel_completes() {
776 use std::sync::atomic::{AtomicU32, Ordering};
777 let counter = Arc::new(AtomicU32::new(0));
778 let max_seen = Arc::new(AtomicU32::new(0));
779 let counter_clone = counter.clone();
780 let max_clone = max_seen.clone();
781
782 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
785 let counter = counter_clone.clone();
786 let max_seen = max_clone.clone();
787 async move {
788 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
789 let mut prev = max_seen.load(Ordering::SeqCst);
790 while now > prev {
791 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
792 Ok(_) => break,
793 Err(p) => prev = p,
794 }
795 }
796 tokio::time::sleep(Duration::from_millis(50)).await;
797 counter.fetch_sub(1, Ordering::SeqCst);
798 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
799 Ok(WorkerResult {
800 value: json!(format!("did:{s}")),
801 ok: true,
802 })
803 }
804 });
805 let svc = build_service(factory);
806 let flow = FlowNode::Fanout {
807 items: path("$.items"),
808 bind: path("$.item"),
809 body: Box::new(step("para", path("$.item"), path("$.r"))),
810 join: JoinMode::All,
811 out: path("$.results"),
812 };
813 let blueprint = bp(flow, vec![agent("para", "para")]);
814 let out = svc
815 .launch(launch_input(
816 blueprint,
817 json!({ "items": ["a", "b", "c", "d"] }),
818 ))
819 .await
820 .expect("launch ok");
821 let results = out.final_ctx["results"].as_array().expect("array");
822 assert_eq!(results.len(), 4);
823 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
824 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
825 }
826 let max = max_seen.load(Ordering::SeqCst);
827 assert!(
828 max >= 2,
829 "expected parallel execution (max inflight >= 2), got {max}"
830 );
831 }
832
833 #[tokio::test]
834 async fn launch_propagates_worker_error_as_flow_eval_err() {
835 let factory = RustFnInProcessSpawnerFactory::new()
836 .register_fn("ok", |inv| async move {
837 Ok(WorkerResult {
838 value: json!(inv.prompt),
839 ok: true,
840 })
841 })
842 .register_fn("boom", |_inv| async move {
843 Err(WorkerError::Failed("intentional boom".into()))
844 });
845 let svc = build_service(factory);
846 let flow = FlowNode::Seq {
847 children: vec![
848 step("ok", path("$.input"), path("$.s1")),
849 step("boom", path("$.s1"), path("$.s2")),
850 step("ok", path("$.s2"), path("$.s3")),
851 ],
852 };
853 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
854 let err = svc
855 .launch(launch_input(blueprint, json!({ "input": "x" })))
856 .await
857 .expect_err("expected fail");
858 match err {
859 TaskLaunchError::FlowEval(msg) => {
860 assert!(
861 msg.contains("boom") || msg.contains("intentional"),
862 "expected error to mention worker failure, got: {msg}"
863 );
864 }
865 other => panic!("expected FlowEval error, got {other:?}"),
866 }
867 }
868
869 #[tokio::test]
870 async fn launch_resolves_call_extern_via_registered_externs() {
871 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
872 Ok(WorkerResult {
873 value: json!({ "echoed": inv.prompt }),
874 ok: true,
875 })
876 });
877 let mut externs = mlua_flow_ir::ExternMap::new();
878 externs.register("fmt.greet", |args: &[Value]| {
879 let name = args[0].as_str().unwrap_or("?");
880 Ok(json!(format!("hello, {name}")))
881 });
882 let svc = build_service(factory).with_externs(Arc::new(externs));
883 let flow = step(
884 "echo",
885 Expr::CallExtern {
886 ref_: "fmt.greet".into(),
887 args: vec![path("$.who")],
888 },
889 path("$.out"),
890 );
891 let blueprint = bp(flow, vec![agent("echo", "echo")]);
892 let out = svc
893 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
894 .await
895 .expect("launch ok");
896 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
897 }
898
899 #[tokio::test]
900 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
901 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
902 Ok(WorkerResult {
903 value: json!(inv.prompt),
904 ok: true,
905 })
906 });
907 let svc = build_service(factory); let flow = step(
909 "echo",
910 Expr::CallExtern {
911 ref_: "fmt.greet".into(),
912 args: vec![],
913 },
914 path("$.out"),
915 );
916 let blueprint = bp(flow, vec![agent("echo", "echo")]);
917 let err = svc
918 .launch(launch_input(blueprint, json!({})))
919 .await
920 .expect_err("expected fail");
921 match err {
922 TaskLaunchError::FlowEval(msg) => {
923 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
924 }
925 other => panic!("expected FlowEval error, got {other:?}"),
926 }
927 }
928
929 #[tokio::test]
934 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
935 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
936 use crate::types::{RunId, TaskId};
937
938 let factory = RustFnInProcessSpawnerFactory::new()
939 .register_fn("upper", |inv| async move {
940 Ok(WorkerResult {
941 value: json!(inv.prompt.to_uppercase()),
942 ok: true,
943 })
944 })
945 .register_fn("suffix", |inv| async move {
946 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
947 Ok(WorkerResult {
948 value: json!(format!("{s}!")),
949 ok: true,
950 })
951 });
952 let svc = build_service(factory);
953 let flow = FlowNode::Seq {
954 children: vec![
955 step("upper", path("$.in"), path("$.s1")),
956 step("suffix", path("$.s1"), path("$.s2")),
957 ],
958 };
959 let blueprint = bp(
960 flow,
961 vec![agent("upper", "upper"), agent("suffix", "suffix")],
962 );
963
964 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
965 let run_id = RunId::new();
966 run_store
967 .create(RunRecord {
968 id: run_id.clone(),
969 task_id: TaskId::new(),
970 status: RunStatus::Running,
971 step_entries: Vec::new(),
972 operator_sid: None,
973 result_ref: None,
974 created_at: 0,
975 updated_at: 0,
976 })
977 .await
978 .expect("seed RunRecord");
979
980 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
981 input.run_ctx = Some(RunContext {
982 run_id: run_id.clone(),
983 run_store: run_store.clone(),
984 });
985
986 let out = svc.launch(input).await.expect("launch ok");
987 assert_eq!(out.final_ctx["s2"], "HI!");
988
989 let run = run_store.get(&run_id).await.expect("run present");
990 assert_eq!(
991 run.step_entries.len(),
992 2,
993 "expected one step_entry per dispatched step, got {:?}",
994 run.step_entries
995 );
996 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
997 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
998 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
999 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1000 }
1001
1002 #[tokio::test]
1003 async fn launch_without_run_ctx_appends_no_step_entries() {
1004 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1008 Ok(WorkerResult {
1009 value: json!(inv.prompt),
1010 ok: true,
1011 })
1012 });
1013 let svc = build_service(factory);
1014 let blueprint = bp(
1015 step("echo", path("$.input"), path("$.out")),
1016 vec![agent("echo", "echo")],
1017 );
1018 let input = launch_input(blueprint, json!({ "input": "hi" }));
1019 assert!(
1020 input.run_ctx.is_none(),
1021 "automate() defaults run_ctx to None"
1022 );
1023 let out = svc.launch(input).await.expect("launch ok");
1024 assert_eq!(out.final_ctx["out"], "hi");
1025 }
1026
1027 #[tokio::test]
1033 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1034 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1041 Ok(WorkerResult {
1042 value: json!({ "echoed": inv.prompt }),
1043 ok: true,
1044 })
1045 });
1046 let svc = build_service(factory);
1047 let blueprint = bp(
1048 step("echo", path("$.input"), path("$.out")),
1049 vec![agent("echo", "echo")],
1050 );
1051 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1052 input.task_input = Some(TaskInputSpec {
1053 project_root: Some("/repo".to_string()),
1054 work_dir: Some("/repo/work".to_string()),
1055 task_metadata: Some(json!({ "issue": 19 })),
1056 });
1057 let out = svc.launch(input).await.expect("launch ok");
1058 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1059 assert!(
1060 out.final_ctx.get("project_root").is_none(),
1061 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1062 out.final_ctx
1063 );
1064 assert!(out.final_ctx.get("work_dir").is_none());
1065 assert!(out.final_ctx.get("task_metadata").is_none());
1066 }
1067
1068 #[tokio::test]
1069 async fn launch_with_task_input_none_is_a_no_op() {
1070 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1071 Ok(WorkerResult {
1072 value: json!(inv.prompt),
1073 ok: true,
1074 })
1075 });
1076 let svc = build_service(factory);
1077 let blueprint = bp(
1078 step("echo", path("$.input"), path("$.out")),
1079 vec![agent("echo", "echo")],
1080 );
1081 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1082 assert!(input.task_input.is_none(), "automate() defaults to None");
1083 input.task_input = None;
1084 let out = svc.launch(input).await.expect("launch ok");
1085 assert_eq!(out.final_ctx["out"], "hi");
1086 }
1087
1088 #[tokio::test]
1089 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1090 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1094 Ok(WorkerResult {
1095 value: json!(inv.prompt),
1096 ok: true,
1097 })
1098 });
1099 let svc = build_service(factory);
1100 let blueprint = bp(
1101 step("echo", path("$.input"), path("$.out")),
1102 vec![agent("echo", "echo")],
1103 );
1104 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1105 input.task_input = Some(TaskInputSpec::default());
1106 let out = svc.launch(input).await.expect("launch ok");
1107 assert_eq!(out.final_ctx["out"], "hi");
1108 }
1109
1110 #[test]
1115 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1116 let bp_default = json!({ "seeded": "from-bp" });
1117 let task = json!({});
1118 let merged = merge_init_ctx(Some(&bp_default), &task);
1119 assert_eq!(merged, json!({ "seeded": "from-bp" }));
1120 }
1121
1122 #[test]
1123 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1124 let bp_default = json!({});
1125 let task = json!({ "seeded": "from-task" });
1126 let merged = merge_init_ctx(Some(&bp_default), &task);
1127 assert_eq!(merged, json!({ "seeded": "from-task" }));
1128 }
1129
1130 #[test]
1131 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1132 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1133 let task = json!({ "a": "task", "c": "task-only" });
1134 let merged = merge_init_ctx(Some(&bp_default), &task);
1135 assert_eq!(
1136 merged,
1137 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1138 );
1139 }
1140
1141 #[test]
1142 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1143 let bp_default = json!({ "seeded": "from-bp" });
1144 let task = json!("plain-string-seed");
1145 let merged = merge_init_ctx(Some(&bp_default), &task);
1146 assert_eq!(merged, json!("plain-string-seed"));
1147 }
1148
1149 #[test]
1150 fn merge_init_ctx_no_bp_default_is_a_no_op() {
1151 let task = json!({ "input": "hi" });
1152 let merged = merge_init_ctx(None, &task);
1153 assert_eq!(merged, task);
1154 }
1155
1156 #[test]
1161 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1162 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1166 let task = json!({ "a": "task", "c": "task-only" });
1167 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1168 let two_layer = merge_init_ctx(Some(&bp_default), &task);
1169 assert_eq!(three_layer, two_layer);
1170 assert_eq!(
1171 three_layer,
1172 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1173 );
1174 }
1175
1176 #[test]
1177 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1178 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1179 let task = json!({ "a": "task", "c": "task-only" });
1180 let run_override = json!({ "a": "run", "d": "run-only" });
1181 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1182 assert_eq!(
1183 merged,
1184 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1185 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1186 );
1187 }
1188
1189 #[test]
1190 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1191 let bp_default = json!({ "seeded": "from-bp" });
1192 let task = json!({ "seeded": "from-task" });
1193 let run_override = json!("plain-string-run-seed");
1194 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1195 assert_eq!(merged, json!("plain-string-run-seed"));
1196 }
1197
1198 #[test]
1199 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1200 let task = json!({ "input": "hi" });
1201 let merged = merge_init_ctx_3layer(None, &task, None);
1202 assert_eq!(merged, task);
1203 }
1204
1205 #[tokio::test]
1206 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1207 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1210 Ok(WorkerResult {
1211 value: json!(inv.prompt),
1212 ok: true,
1213 })
1214 });
1215 let svc = build_service(factory);
1216 let mut blueprint = bp(
1217 step("echo", path("$.greeting"), path("$.out")),
1218 vec![agent("echo", "echo")],
1219 );
1220 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1221 let out = svc
1223 .launch(launch_input(blueprint, json!({})))
1224 .await
1225 .expect("launch ok");
1226 assert_eq!(out.final_ctx["out"], "hello from bp");
1227 }
1228
1229 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1234 AgentDef {
1235 name: name.to_string(),
1236 kind: AgentKind::RustFn,
1237 spec: json!({ "fn_id": fn_id }),
1238 profile: None,
1239 meta: Some(meta),
1240 }
1241 }
1242
1243 #[test]
1244 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1245 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1246 let (global, per_agent) = derive_agent_ctx(&blueprint);
1247 assert_eq!(global, None);
1248 assert!(per_agent.is_empty());
1249 }
1250
1251 #[test]
1252 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1253 let mut blueprint = bp(
1254 step("echo", path("$.in"), path("$.out")),
1255 vec![
1256 agent_with_meta(
1257 "with-ctx",
1258 "echo",
1259 AgentMeta {
1260 ctx: Some(json!({ "org_conventions": "x" })),
1261 ..Default::default()
1262 },
1263 ),
1264 agent("no-ctx", "echo"),
1265 ],
1266 );
1267 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1268 let (global, per_agent) = derive_agent_ctx(&blueprint);
1269 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1270 assert_eq!(
1271 per_agent.len(),
1272 1,
1273 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1274 );
1275 assert_eq!(
1276 per_agent.get("with-ctx"),
1277 Some(&json!({ "org_conventions": "x" }))
1278 );
1279 assert!(!per_agent.contains_key("no-ctx"));
1280 }
1281
1282 #[test]
1283 fn derive_context_policies_empty_blueprint_yields_empty_state() {
1284 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1285 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1286 assert_eq!(default_policy, None);
1287 assert!(per_agent.is_empty());
1288 }
1289
1290 #[test]
1291 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1292 let mut blueprint = bp(
1293 step("echo", path("$.in"), path("$.out")),
1294 vec![
1295 agent_with_meta(
1296 "with-policy",
1297 "echo",
1298 AgentMeta {
1299 context_policy: Some(ContextPolicy {
1300 include: None,
1301 exclude: vec!["work_dir".to_string()],
1302 ..Default::default()
1303 }),
1304 ..Default::default()
1305 },
1306 ),
1307 agent("no-policy", "echo"),
1308 ],
1309 );
1310 blueprint.default_context_policy = Some(ContextPolicy {
1311 include: Some(vec!["project_root".to_string()]),
1312 exclude: vec![],
1313 ..Default::default()
1314 });
1315 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1316 assert_eq!(
1317 default_policy,
1318 Some(ContextPolicy {
1319 include: Some(vec!["project_root".to_string()]),
1320 exclude: vec![],
1321 ..Default::default()
1322 })
1323 );
1324 assert_eq!(per_agent.len(), 1);
1325 assert_eq!(
1326 per_agent.get("with-policy"),
1327 Some(&ContextPolicy {
1328 include: None,
1329 exclude: vec!["work_dir".to_string()],
1330 ..Default::default()
1331 })
1332 );
1333 assert!(!per_agent.contains_key("no-policy"));
1334 }
1335
1336 #[test]
1342 fn derive_step_metas_empty_blueprint_yields_empty_map() {
1343 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1344 assert!(derive_step_metas(&blueprint).is_empty());
1345 }
1346
1347 #[test]
1348 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1349 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1350 blueprint.metas = vec![
1351 MetaDef {
1352 name: "heavy-scan".to_string(),
1353 ctx: json!({ "work_dir": "/x" }),
1354 },
1355 MetaDef {
1356 name: "light-scan".to_string(),
1357 ctx: json!({ "work_dir": "/y" }),
1358 },
1359 ];
1360 let metas = derive_step_metas(&blueprint);
1361 assert_eq!(metas.len(), 2);
1362 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1363 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1364 }
1365
1366 #[test]
1367 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1368 let mut blueprint = bp(
1369 step("echo", path("$.in"), path("$.out")),
1370 vec![agent_with_meta(
1371 "with-meta-ref",
1372 "echo",
1373 AgentMeta {
1374 ctx: Some(json!({ "work_dir": "/inline-wins" })),
1375 meta_ref: Some("shared".to_string()),
1376 ..Default::default()
1377 },
1378 )],
1379 );
1380 blueprint.metas = vec![MetaDef {
1381 name: "shared".to_string(),
1382 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1383 }];
1384 let (_, per_agent) = derive_agent_ctx(&blueprint);
1385 assert_eq!(
1386 per_agent.get("with-meta-ref"),
1387 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1388 "inline ctx must win the collided key while pool-only keys survive the merge"
1389 );
1390 }
1391
1392 #[test]
1393 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1394 let mut blueprint = bp(
1395 step("echo", path("$.in"), path("$.out")),
1396 vec![agent_with_meta(
1397 "with-meta-ref-only",
1398 "echo",
1399 AgentMeta {
1400 meta_ref: Some("shared".to_string()),
1401 ..Default::default()
1402 },
1403 )],
1404 );
1405 blueprint.metas = vec![MetaDef {
1406 name: "shared".to_string(),
1407 ctx: json!({ "work_dir": "/base" }),
1408 }];
1409 let (_, per_agent) = derive_agent_ctx(&blueprint);
1410 assert_eq!(
1411 per_agent.get("with-meta-ref-only"),
1412 Some(&json!({ "work_dir": "/base" }))
1413 );
1414 }
1415
1416 #[test]
1417 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1418 let blueprint = bp(
1419 step("echo", path("$.in"), path("$.out")),
1420 vec![agent_with_meta(
1421 "with-unresolved-meta-ref",
1422 "echo",
1423 AgentMeta {
1424 ctx: Some(json!({ "work_dir": "/inline-only" })),
1425 meta_ref: Some("missing".to_string()),
1426 ..Default::default()
1427 },
1428 )],
1429 );
1430 let (_, per_agent) = derive_agent_ctx(&blueprint);
1432 assert_eq!(
1433 per_agent.get("with-unresolved-meta-ref"),
1434 Some(&json!({ "work_dir": "/inline-only" })),
1435 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1436 );
1437 }
1438}