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