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
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_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
98 blueprint.audits.clone()
99}
100
101fn 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
161fn 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 let spawner = linker::link(
514 compiled.router.clone(),
515 &input.blueprint.spawner_hints.layers,
516 &self.engine,
517 );
518 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
534 let (context_policy_default, context_policy_per_agent) =
535 derive_context_policies(&input.blueprint);
536 let spawner = SpawnerStack::new(spawner)
537 .layer(AgentContextMiddleware::new(
538 agent_ctx_global,
539 agent_ctx_per_agent,
540 context_policy_default,
541 context_policy_per_agent,
542 ))
543 .build();
544 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
551 SpawnerStack::new(spawner)
552 .layer(ProjectNameAliasMiddleware::new(alias))
553 .build()
554 } else {
555 spawner
556 };
557 let worker_bindings = derive_worker_bindings(&input.blueprint);
561 let spawner = if worker_bindings.is_empty() {
562 spawner
563 } else {
564 SpawnerStack::new(spawner)
565 .layer(WorkerBindingMiddleware::new(worker_bindings))
566 .build()
567 };
568 let audit_defs = derive_audits(&input.blueprint);
578 let spawner = if audit_defs.is_empty() {
579 spawner
580 } else {
581 SpawnerStack::new(spawner)
582 .layer(AfterRunAuditMiddleware::new(
583 audit_defs,
584 compiled.router.clone(),
585 ))
586 .build()
587 };
588
589 let spawner = match input.task_input.as_ref().and_then(|spec| {
596 TaskInputMiddleware::new_from_fields(
597 spec.project_root.clone(),
598 spec.work_dir.clone(),
599 spec.task_metadata.clone(),
600 )
601 }) {
602 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
603 None => spawner,
604 };
605
606 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
611 let bp_global_kind = input
612 .blueprint
613 .default_operator_kind
614 .map(OperatorKind::from);
615
616 let token = self
617 .engine
618 .attach_with_ids(
619 input.operator_id,
620 input.role,
621 input.ttl,
622 input.operator_kind,
623 input.bridge_id,
624 input.hook_id,
625 input.operator_backend_id,
626 input.operator_kind_overrides,
627 bp_agent_kinds,
628 bp_global_kind,
629 )
630 .await?;
631 let dispatcher =
632 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
633 let dispatcher = match input.run_ctx {
634 Some(run_ctx) => dispatcher.with_run(run_ctx),
635 None => dispatcher,
636 };
637 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
641 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
647 let dispatcher =
654 dispatcher.with_projection_placement(compiled.projection_placement.clone());
655 let merged_init_ctx =
661 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
662 let final_ctx = mlua_flow_ir::eval_async_externs(
663 &input.blueprint.flow,
664 merged_init_ctx,
665 &dispatcher,
666 &*self.externs,
667 )
668 .await
669 .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
670 Ok(TaskLaunchOutput { token, final_ctx })
671 }
672}
673
674#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
682 use crate::blueprint::{
683 current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
684 CompilerStrategy, MetaDef,
685 };
686 use crate::core::config::EngineCfg;
687 use crate::worker::adapter::{WorkerError, WorkerResult};
688 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
689 use serde_json::json;
690 use std::sync::Arc;
691
692 fn path(s: &str) -> Expr {
693 Expr::Path { at: s.to_string() }
694 }
695 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
696 FlowNode::Step {
697 ref_: ref_.to_string(),
698 in_,
699 out,
700 }
701 }
702
703 fn agent(name: &str, fn_id: &str) -> AgentDef {
704 AgentDef {
705 name: name.to_string(),
706 kind: AgentKind::RustFn,
707 spec: json!({ "fn_id": fn_id }),
708 profile: None,
709 meta: Some(AgentMeta::default()),
710 }
711 }
712
713 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
714 let engine = Engine::new(EngineCfg::default());
715 let mut reg = SpawnerRegistry::new();
716 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
717 let compiler = Compiler::new(reg);
718 TaskLaunchService::new(engine, compiler)
719 }
720
721 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
722 Blueprint {
723 schema_version: current_schema_version(),
724 id: "ut".into(),
725 flow,
726 agents,
727 operators: vec![],
728 metas: vec![],
729 hints: CompilerHints::default(),
730 strategy: CompilerStrategy::default(),
731 metadata: BlueprintMetadata::default(),
732 spawner_hints: Default::default(),
733 default_agent_kind: AgentKind::Operator,
734 default_operator_kind: None,
735 default_init_ctx: None,
736 default_agent_ctx: None,
737 default_context_policy: None,
738 projection_placement: None,
739 audits: vec![],
740 degradation_policy: None,
741 }
742 }
743
744 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
745 TaskLaunchInput::automate(
746 blueprint,
747 "ut-op",
748 Role::Operator,
749 Duration::from_secs(30),
750 init_ctx,
751 )
752 }
753
754 #[test]
760 fn derive_audits_empty_by_default() {
761 let blueprint = bp(
762 step("echo", path("$.input"), path("$.out")),
763 vec![agent("echo", "echo")],
764 );
765 assert!(
766 derive_audits(&blueprint).is_empty(),
767 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
768 );
769 }
770
771 #[test]
772 fn derive_audits_returns_blueprint_audits_verbatim() {
773 let mut blueprint = bp(
774 step("echo", path("$.input"), path("$.out")),
775 vec![agent("echo", "echo")],
776 );
777 blueprint.audits = vec![crate::blueprint::AuditDef {
778 agent: "auditor".to_string(),
779 steps: None,
780 mode: crate::blueprint::AuditMode::Async,
781 }];
782 let got = derive_audits(&blueprint);
783 assert_eq!(got.len(), 1);
784 assert_eq!(got[0].agent, "auditor");
785 }
786
787 #[tokio::test]
788 async fn launch_appends_audit_artifact_when_audits_declared() {
789 use crate::blueprint::{AuditDef, AuditMode};
790
791 let factory = RustFnInProcessSpawnerFactory::new()
792 .register_fn("echo", |inv| async move {
793 Ok(WorkerResult {
794 value: json!({ "echoed": inv.prompt }),
795 ok: true,
796 })
797 })
798 .register_fn("audit-fn", |_inv| async move {
799 Ok(WorkerResult {
800 value: json!({ "finding": "clean" }),
801 ok: true,
802 })
803 });
804 let svc = build_service(factory);
805 let mut blueprint = bp(
806 step("echo", path("$.input"), path("$.out")),
807 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
808 );
809 blueprint.audits = vec![AuditDef {
810 agent: "auditor".to_string(),
811 steps: None,
812 mode: AuditMode::Sync,
813 }];
814 let out = svc
815 .launch(launch_input(blueprint, json!({ "input": "hi" })))
816 .await
817 .expect("launch ok — audits must never alter the audited step's outcome");
818 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
819
820 let audited_task_id = svc
821 .engine()
822 .with_state("test.find_audited_task", |s| {
823 s.tasks
824 .iter()
825 .find(|(_, t)| t.spec.agent == "echo")
826 .map(|(id, _)| id.clone())
827 })
828 .await
829 .expect("with_state")
830 .expect("the echo task must exist");
831 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
832 let found = tail.iter().any(|ev| {
833 matches!(
834 ev,
835 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
836 )
837 });
838 assert!(
839 found,
840 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
841 );
842 }
843
844 #[tokio::test]
845 async fn launch_single_step_writes_out_path() {
846 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
847 Ok(WorkerResult {
848 value: json!({ "echoed": inv.prompt }),
849 ok: true,
850 })
851 });
852 let svc = build_service(factory);
853 let blueprint = bp(
854 step("echo", path("$.input"), path("$.out")),
855 vec![agent("echo", "echo")],
856 );
857 let out = svc
858 .launch(launch_input(blueprint, json!({ "input": "hi" })))
859 .await
860 .expect("launch ok");
861 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
862 }
863
864 #[tokio::test]
865 async fn launch_three_step_seq_threads_ctx_forward() {
866 let factory = RustFnInProcessSpawnerFactory::new()
867 .register_fn("upper", |inv| async move {
868 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
869 Ok(WorkerResult {
870 value: json!(s.to_uppercase()),
871 ok: true,
872 })
873 })
874 .register_fn("suffix", |inv| async move {
875 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
876 Ok(WorkerResult {
877 value: json!(format!("{s}!")),
878 ok: true,
879 })
880 })
881 .register_fn("wrap", |inv| async move {
882 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
883 Ok(WorkerResult {
884 value: json!(format!("[{s}]")),
885 ok: true,
886 })
887 });
888 let svc = build_service(factory);
889 let flow = FlowNode::Seq {
890 children: vec![
891 step("upper", path("$.in"), path("$.s1")),
892 step("suffix", path("$.s1"), path("$.s2")),
893 step("wrap", path("$.s2"), path("$.s3")),
894 ],
895 };
896 let blueprint = bp(
897 flow,
898 vec![
899 agent("upper", "upper"),
900 agent("suffix", "suffix"),
901 agent("wrap", "wrap"),
902 ],
903 );
904 let out = svc
905 .launch(launch_input(blueprint, json!({ "in": "hello" })))
906 .await
907 .expect("launch ok");
908 assert_eq!(out.final_ctx["s1"], "HELLO");
909 assert_eq!(out.final_ctx["s2"], "HELLO!");
910 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
911 }
912
913 #[tokio::test]
914 async fn launch_fanout_join_all_parallel_completes() {
915 use std::sync::atomic::{AtomicU32, Ordering};
916 let counter = Arc::new(AtomicU32::new(0));
917 let max_seen = Arc::new(AtomicU32::new(0));
918 let counter_clone = counter.clone();
919 let max_clone = max_seen.clone();
920
921 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
924 let counter = counter_clone.clone();
925 let max_seen = max_clone.clone();
926 async move {
927 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
928 let mut prev = max_seen.load(Ordering::SeqCst);
929 while now > prev {
930 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
931 Ok(_) => break,
932 Err(p) => prev = p,
933 }
934 }
935 tokio::time::sleep(Duration::from_millis(50)).await;
936 counter.fetch_sub(1, Ordering::SeqCst);
937 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
938 Ok(WorkerResult {
939 value: json!(format!("did:{s}")),
940 ok: true,
941 })
942 }
943 });
944 let svc = build_service(factory);
945 let flow = FlowNode::Fanout {
946 items: path("$.items"),
947 bind: path("$.item"),
948 body: Box::new(step("para", path("$.item"), path("$.r"))),
949 join: JoinMode::All,
950 out: path("$.results"),
951 };
952 let blueprint = bp(flow, vec![agent("para", "para")]);
953 let out = svc
954 .launch(launch_input(
955 blueprint,
956 json!({ "items": ["a", "b", "c", "d"] }),
957 ))
958 .await
959 .expect("launch ok");
960 let results = out.final_ctx["results"].as_array().expect("array");
961 assert_eq!(results.len(), 4);
962 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
963 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
964 }
965 let max = max_seen.load(Ordering::SeqCst);
966 assert!(
967 max >= 2,
968 "expected parallel execution (max inflight >= 2), got {max}"
969 );
970 }
971
972 #[tokio::test]
973 async fn launch_propagates_worker_error_as_flow_eval_err() {
974 let factory = RustFnInProcessSpawnerFactory::new()
975 .register_fn("ok", |inv| async move {
976 Ok(WorkerResult {
977 value: json!(inv.prompt),
978 ok: true,
979 })
980 })
981 .register_fn("boom", |_inv| async move {
982 Err(WorkerError::Failed("intentional boom".into()))
983 });
984 let svc = build_service(factory);
985 let flow = FlowNode::Seq {
986 children: vec![
987 step("ok", path("$.input"), path("$.s1")),
988 step("boom", path("$.s1"), path("$.s2")),
989 step("ok", path("$.s2"), path("$.s3")),
990 ],
991 };
992 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
993 let err = svc
994 .launch(launch_input(blueprint, json!({ "input": "x" })))
995 .await
996 .expect_err("expected fail");
997 match err {
998 TaskLaunchError::FlowEval(msg) => {
999 assert!(
1000 msg.contains("boom") || msg.contains("intentional"),
1001 "expected error to mention worker failure, got: {msg}"
1002 );
1003 }
1004 other => panic!("expected FlowEval error, got {other:?}"),
1005 }
1006 }
1007
1008 #[tokio::test]
1009 async fn launch_resolves_call_extern_via_registered_externs() {
1010 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1011 Ok(WorkerResult {
1012 value: json!({ "echoed": inv.prompt }),
1013 ok: true,
1014 })
1015 });
1016 let mut externs = mlua_flow_ir::ExternMap::new();
1017 externs.register("fmt.greet", |args: &[Value]| {
1018 let name = args[0].as_str().unwrap_or("?");
1019 Ok(json!(format!("hello, {name}")))
1020 });
1021 let svc = build_service(factory).with_externs(Arc::new(externs));
1022 let flow = step(
1023 "echo",
1024 Expr::CallExtern {
1025 ref_: "fmt.greet".into(),
1026 args: vec![path("$.who")],
1027 },
1028 path("$.out"),
1029 );
1030 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1031 let out = svc
1032 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1033 .await
1034 .expect("launch ok");
1035 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1036 }
1037
1038 #[tokio::test]
1039 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1040 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1041 Ok(WorkerResult {
1042 value: json!(inv.prompt),
1043 ok: true,
1044 })
1045 });
1046 let svc = build_service(factory); let flow = step(
1048 "echo",
1049 Expr::CallExtern {
1050 ref_: "fmt.greet".into(),
1051 args: vec![],
1052 },
1053 path("$.out"),
1054 );
1055 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1056 let err = svc
1057 .launch(launch_input(blueprint, json!({})))
1058 .await
1059 .expect_err("expected fail");
1060 match err {
1061 TaskLaunchError::FlowEval(msg) => {
1062 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1063 }
1064 other => panic!("expected FlowEval error, got {other:?}"),
1065 }
1066 }
1067
1068 #[tokio::test]
1073 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1074 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1075 use crate::types::{RunId, TaskId};
1076
1077 let factory = RustFnInProcessSpawnerFactory::new()
1078 .register_fn("upper", |inv| async move {
1079 Ok(WorkerResult {
1080 value: json!(inv.prompt.to_uppercase()),
1081 ok: true,
1082 })
1083 })
1084 .register_fn("suffix", |inv| async move {
1085 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1086 Ok(WorkerResult {
1087 value: json!(format!("{s}!")),
1088 ok: true,
1089 })
1090 });
1091 let svc = build_service(factory);
1092 let flow = FlowNode::Seq {
1093 children: vec![
1094 step("upper", path("$.in"), path("$.s1")),
1095 step("suffix", path("$.s1"), path("$.s2")),
1096 ],
1097 };
1098 let blueprint = bp(
1099 flow,
1100 vec![agent("upper", "upper"), agent("suffix", "suffix")],
1101 );
1102
1103 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1104 let run_id = RunId::new();
1105 run_store
1106 .create(RunRecord {
1107 id: run_id.clone(),
1108 task_id: TaskId::new(),
1109 status: RunStatus::Running,
1110 step_entries: Vec::new(),
1111 degradations: Vec::new(),
1112 operator_sid: None,
1113 result_ref: None,
1114 created_at: 0,
1115 updated_at: 0,
1116 })
1117 .await
1118 .expect("seed RunRecord");
1119
1120 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
1121 input.run_ctx = Some(RunContext {
1122 run_id: run_id.clone(),
1123 run_store: run_store.clone(),
1124 });
1125
1126 let out = svc.launch(input).await.expect("launch ok");
1127 assert_eq!(out.final_ctx["s2"], "HI!");
1128
1129 let run = run_store.get(&run_id).await.expect("run present");
1130 assert_eq!(
1131 run.step_entries.len(),
1132 2,
1133 "expected one step_entry per dispatched step, got {:?}",
1134 run.step_entries
1135 );
1136 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1137 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1138 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1139 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1140 }
1141
1142 #[tokio::test]
1143 async fn launch_without_run_ctx_appends_no_step_entries() {
1144 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1148 Ok(WorkerResult {
1149 value: json!(inv.prompt),
1150 ok: true,
1151 })
1152 });
1153 let svc = build_service(factory);
1154 let blueprint = bp(
1155 step("echo", path("$.input"), path("$.out")),
1156 vec![agent("echo", "echo")],
1157 );
1158 let input = launch_input(blueprint, json!({ "input": "hi" }));
1159 assert!(
1160 input.run_ctx.is_none(),
1161 "automate() defaults run_ctx to None"
1162 );
1163 let out = svc.launch(input).await.expect("launch ok");
1164 assert_eq!(out.final_ctx["out"], "hi");
1165 }
1166
1167 #[tokio::test]
1173 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1174 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1181 Ok(WorkerResult {
1182 value: json!({ "echoed": inv.prompt }),
1183 ok: true,
1184 })
1185 });
1186 let svc = build_service(factory);
1187 let blueprint = bp(
1188 step("echo", path("$.input"), path("$.out")),
1189 vec![agent("echo", "echo")],
1190 );
1191 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1192 input.task_input = Some(TaskInputSpec {
1193 project_root: Some("/repo".to_string()),
1194 work_dir: Some("/repo/work".to_string()),
1195 task_metadata: Some(json!({ "issue": 19 })),
1196 });
1197 let out = svc.launch(input).await.expect("launch ok");
1198 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1199 assert!(
1200 out.final_ctx.get("project_root").is_none(),
1201 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1202 out.final_ctx
1203 );
1204 assert!(out.final_ctx.get("work_dir").is_none());
1205 assert!(out.final_ctx.get("task_metadata").is_none());
1206 }
1207
1208 #[tokio::test]
1209 async fn launch_with_task_input_none_is_a_no_op() {
1210 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1211 Ok(WorkerResult {
1212 value: json!(inv.prompt),
1213 ok: true,
1214 })
1215 });
1216 let svc = build_service(factory);
1217 let blueprint = bp(
1218 step("echo", path("$.input"), path("$.out")),
1219 vec![agent("echo", "echo")],
1220 );
1221 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1222 assert!(input.task_input.is_none(), "automate() defaults to None");
1223 input.task_input = None;
1224 let out = svc.launch(input).await.expect("launch ok");
1225 assert_eq!(out.final_ctx["out"], "hi");
1226 }
1227
1228 #[tokio::test]
1229 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1230 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1234 Ok(WorkerResult {
1235 value: json!(inv.prompt),
1236 ok: true,
1237 })
1238 });
1239 let svc = build_service(factory);
1240 let blueprint = bp(
1241 step("echo", path("$.input"), path("$.out")),
1242 vec![agent("echo", "echo")],
1243 );
1244 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1245 input.task_input = Some(TaskInputSpec::default());
1246 let out = svc.launch(input).await.expect("launch ok");
1247 assert_eq!(out.final_ctx["out"], "hi");
1248 }
1249
1250 #[test]
1255 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1256 let bp_default = json!({ "seeded": "from-bp" });
1257 let task = json!({});
1258 let merged = merge_init_ctx(Some(&bp_default), &task);
1259 assert_eq!(merged, json!({ "seeded": "from-bp" }));
1260 }
1261
1262 #[test]
1263 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1264 let bp_default = json!({});
1265 let task = json!({ "seeded": "from-task" });
1266 let merged = merge_init_ctx(Some(&bp_default), &task);
1267 assert_eq!(merged, json!({ "seeded": "from-task" }));
1268 }
1269
1270 #[test]
1271 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1272 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1273 let task = json!({ "a": "task", "c": "task-only" });
1274 let merged = merge_init_ctx(Some(&bp_default), &task);
1275 assert_eq!(
1276 merged,
1277 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1278 );
1279 }
1280
1281 #[test]
1282 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1283 let bp_default = json!({ "seeded": "from-bp" });
1284 let task = json!("plain-string-seed");
1285 let merged = merge_init_ctx(Some(&bp_default), &task);
1286 assert_eq!(merged, json!("plain-string-seed"));
1287 }
1288
1289 #[test]
1290 fn merge_init_ctx_no_bp_default_is_a_no_op() {
1291 let task = json!({ "input": "hi" });
1292 let merged = merge_init_ctx(None, &task);
1293 assert_eq!(merged, task);
1294 }
1295
1296 #[test]
1301 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1302 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1306 let task = json!({ "a": "task", "c": "task-only" });
1307 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1308 let two_layer = merge_init_ctx(Some(&bp_default), &task);
1309 assert_eq!(three_layer, two_layer);
1310 assert_eq!(
1311 three_layer,
1312 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1313 );
1314 }
1315
1316 #[test]
1317 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1318 let bp_default = json!({ "a": "bp", "b": "bp-only" });
1319 let task = json!({ "a": "task", "c": "task-only" });
1320 let run_override = json!({ "a": "run", "d": "run-only" });
1321 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1322 assert_eq!(
1323 merged,
1324 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1325 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1326 );
1327 }
1328
1329 #[test]
1330 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1331 let bp_default = json!({ "seeded": "from-bp" });
1332 let task = json!({ "seeded": "from-task" });
1333 let run_override = json!("plain-string-run-seed");
1334 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1335 assert_eq!(merged, json!("plain-string-run-seed"));
1336 }
1337
1338 #[test]
1339 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1340 let task = json!({ "input": "hi" });
1341 let merged = merge_init_ctx_3layer(None, &task, None);
1342 assert_eq!(merged, task);
1343 }
1344
1345 #[tokio::test]
1346 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1347 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1350 Ok(WorkerResult {
1351 value: json!(inv.prompt),
1352 ok: true,
1353 })
1354 });
1355 let svc = build_service(factory);
1356 let mut blueprint = bp(
1357 step("echo", path("$.greeting"), path("$.out")),
1358 vec![agent("echo", "echo")],
1359 );
1360 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1361 let out = svc
1363 .launch(launch_input(blueprint, json!({})))
1364 .await
1365 .expect("launch ok");
1366 assert_eq!(out.final_ctx["out"], "hello from bp");
1367 }
1368
1369 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1374 AgentDef {
1375 name: name.to_string(),
1376 kind: AgentKind::RustFn,
1377 spec: json!({ "fn_id": fn_id }),
1378 profile: None,
1379 meta: Some(meta),
1380 }
1381 }
1382
1383 #[test]
1384 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1385 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1386 let (global, per_agent) = derive_agent_ctx(&blueprint);
1387 assert_eq!(global, None);
1388 assert!(per_agent.is_empty());
1389 }
1390
1391 #[test]
1392 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1393 let mut blueprint = bp(
1394 step("echo", path("$.in"), path("$.out")),
1395 vec![
1396 agent_with_meta(
1397 "with-ctx",
1398 "echo",
1399 AgentMeta {
1400 ctx: Some(json!({ "org_conventions": "x" })),
1401 ..Default::default()
1402 },
1403 ),
1404 agent("no-ctx", "echo"),
1405 ],
1406 );
1407 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1408 let (global, per_agent) = derive_agent_ctx(&blueprint);
1409 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1410 assert_eq!(
1411 per_agent.len(),
1412 1,
1413 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1414 );
1415 assert_eq!(
1416 per_agent.get("with-ctx"),
1417 Some(&json!({ "org_conventions": "x" }))
1418 );
1419 assert!(!per_agent.contains_key("no-ctx"));
1420 }
1421
1422 #[test]
1423 fn derive_context_policies_empty_blueprint_yields_empty_state() {
1424 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1425 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1426 assert_eq!(default_policy, None);
1427 assert!(per_agent.is_empty());
1428 }
1429
1430 #[test]
1431 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1432 let mut blueprint = bp(
1433 step("echo", path("$.in"), path("$.out")),
1434 vec![
1435 agent_with_meta(
1436 "with-policy",
1437 "echo",
1438 AgentMeta {
1439 context_policy: Some(ContextPolicy {
1440 include: None,
1441 exclude: vec!["work_dir".to_string()],
1442 ..Default::default()
1443 }),
1444 ..Default::default()
1445 },
1446 ),
1447 agent("no-policy", "echo"),
1448 ],
1449 );
1450 blueprint.default_context_policy = Some(ContextPolicy {
1451 include: Some(vec!["project_root".to_string()]),
1452 exclude: vec![],
1453 ..Default::default()
1454 });
1455 let (default_policy, per_agent) = derive_context_policies(&blueprint);
1456 assert_eq!(
1457 default_policy,
1458 Some(ContextPolicy {
1459 include: Some(vec!["project_root".to_string()]),
1460 exclude: vec![],
1461 ..Default::default()
1462 })
1463 );
1464 assert_eq!(per_agent.len(), 1);
1465 assert_eq!(
1466 per_agent.get("with-policy"),
1467 Some(&ContextPolicy {
1468 include: None,
1469 exclude: vec!["work_dir".to_string()],
1470 ..Default::default()
1471 })
1472 );
1473 assert!(!per_agent.contains_key("no-policy"));
1474 }
1475
1476 #[test]
1482 fn derive_step_metas_empty_blueprint_yields_empty_map() {
1483 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1484 assert!(derive_step_metas(&blueprint).is_empty());
1485 }
1486
1487 #[test]
1488 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1489 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1490 blueprint.metas = vec![
1491 MetaDef {
1492 name: "heavy-scan".to_string(),
1493 ctx: json!({ "work_dir": "/x" }),
1494 },
1495 MetaDef {
1496 name: "light-scan".to_string(),
1497 ctx: json!({ "work_dir": "/y" }),
1498 },
1499 ];
1500 let metas = derive_step_metas(&blueprint);
1501 assert_eq!(metas.len(), 2);
1502 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1503 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1504 }
1505
1506 #[test]
1507 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1508 let mut blueprint = bp(
1509 step("echo", path("$.in"), path("$.out")),
1510 vec![agent_with_meta(
1511 "with-meta-ref",
1512 "echo",
1513 AgentMeta {
1514 ctx: Some(json!({ "work_dir": "/inline-wins" })),
1515 meta_ref: Some("shared".to_string()),
1516 ..Default::default()
1517 },
1518 )],
1519 );
1520 blueprint.metas = vec![MetaDef {
1521 name: "shared".to_string(),
1522 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1523 }];
1524 let (_, per_agent) = derive_agent_ctx(&blueprint);
1525 assert_eq!(
1526 per_agent.get("with-meta-ref"),
1527 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1528 "inline ctx must win the collided key while pool-only keys survive the merge"
1529 );
1530 }
1531
1532 #[test]
1533 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1534 let mut blueprint = bp(
1535 step("echo", path("$.in"), path("$.out")),
1536 vec![agent_with_meta(
1537 "with-meta-ref-only",
1538 "echo",
1539 AgentMeta {
1540 meta_ref: Some("shared".to_string()),
1541 ..Default::default()
1542 },
1543 )],
1544 );
1545 blueprint.metas = vec![MetaDef {
1546 name: "shared".to_string(),
1547 ctx: json!({ "work_dir": "/base" }),
1548 }];
1549 let (_, per_agent) = derive_agent_ctx(&blueprint);
1550 assert_eq!(
1551 per_agent.get("with-meta-ref-only"),
1552 Some(&json!({ "work_dir": "/base" }))
1553 );
1554 }
1555
1556 #[test]
1557 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1558 let blueprint = bp(
1559 step("echo", path("$.in"), path("$.out")),
1560 vec![agent_with_meta(
1561 "with-unresolved-meta-ref",
1562 "echo",
1563 AgentMeta {
1564 ctx: Some(json!({ "work_dir": "/inline-only" })),
1565 meta_ref: Some("missing".to_string()),
1566 ..Default::default()
1567 },
1568 )],
1569 );
1570 let (_, per_agent) = derive_agent_ctx(&blueprint);
1572 assert_eq!(
1573 per_agent.get("with-unresolved-meta-ref"),
1574 Some(&json!({ "work_dir": "/inline-only" })),
1575 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1576 );
1577 }
1578}