Skip to main content

mlua_swarm/
blueprint.rs

1//! Blueprint runner — glue that executes a flow.ir AST
2//! (`mlua_flow_ir::Node`) through the engine. Each `Step.ref` is run as a
3//! single task via `start_task` + `dispatch_attempt_with`, and the
4//! resulting `Pass` `Value` is written back to `Step.out`.
5//!
6//! **Fully-async chain.** Uses `mlua_flow_ir::eval_async` and
7//! `AsyncDispatcher`; `block_on` and `spawn_blocking` are never mixed in,
8//! so the whole stack stays consistent with the engine's tokio async
9//! world.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
15//! let bp: mlua_flow_ir::Node = serde_json::from_str(BP_JSON)?;
16//! let final_ctx = mlua_flow_ir::eval_async(&bp, init_ctx, &dispatcher).await?;
17//! ```
18//!
19//! # Schema types (the IF crate)
20//!
21//! `Blueprint` / `AgentDef` / `AgentKind` and friends live in the
22//! `mlua_swarm_schema` crate and are re-exported from here.
23//! The `struct`/`enum` set that used to live directly in `src/blueprint.rs`
24//! has been moved into the IF crate to support extension discipline,
25//! versioning, and external consumers.
26
27use crate::core::engine::Engine;
28use crate::core::state::{DispatchOutcome, TaskSpec};
29use crate::store::run::{RunContext, StepEntry};
30use crate::types::{now_unix, CapToken};
31use crate::worker::adapter::SpawnerAdapter;
32use async_trait::async_trait;
33pub mod compiler;
34pub mod loader;
35pub mod store;
36
37use mlua_flow_ir::{AsyncDispatcher, EvalError};
38use serde_json::{Map, Value};
39use std::collections::HashMap;
40use std::sync::Arc;
41
42// The schema types are owned by the IF crate (mlua-swarm-schema); we re-export them here.
43/// The schema-side `OperatorKind` (see `crate::core::ctx::OperatorKind` for the
44/// runtime duplicate consumed by `Engine`). Re-exported under an explicit
45/// alias so callers reading `Blueprint.operators[].kind` /
46/// `Blueprint.default_operator_kind` do not have to reach into
47/// `mlua_swarm_schema` directly.
48pub use mlua_swarm_schema::OperatorKind as SchemaOperatorKind;
49pub use mlua_swarm_schema::{
50    current_schema_version, default_global_agent_kind, AgentDef, AgentKind, AgentMeta,
51    AgentProfile, Blueprint, BlueprintMetadata, BlueprintOrigin, CompilerHints, CompilerStrategy,
52    MetaDef, OperatorDef, SpawnerHints, CURRENT_SCHEMA_VERSION,
53};
54
55/// Bridges `mlua_flow_ir::AsyncDispatcher` to the engine's
56/// `start_task` + `dispatch_attempt_with` pair. Holds one Operator session
57/// token and one `spawner`, and spins up a fresh task per `Step.ref`, using
58/// it as the agent name.
59///
60/// Constructed via `with_spawner`; each dispatch goes through
61/// `engine.dispatch_attempt_with(token, tid, spawner, run_id)`, carrying the
62/// spawner per request. Nothing is stashed on engine-global state, so
63/// multiple dispatchers can drive different Blueprints against the same
64/// `Engine` in parallel without racing.
65///
66/// Optionally carries a [`RunContext`] (via [`Self::with_run`], issue #13
67/// run_id propagation): when present, every dispatched step's `run_id` is
68/// exposed to the worker through `Ctx.meta.runtime["run_id"]`, and a
69/// [`StepEntry`] is appended to `RunRecord.step_entries` once the step's
70/// outcome is known (dispatch is synchronous end-to-end here, so there is
71/// no need for a separate event/notification mechanism — the entry is
72/// written with its final status in one call).
73///
74/// Also carries the GH #21 Phase 2 named `MetaDef` pool (via
75/// [`Self::with_step_metas`]) — the Step tier's dispatch-time resolver;
76/// see [`Self::dispatch`]'s doc for the full envelope contract.
77pub struct EngineDispatcher {
78    engine: Engine,
79    op_token: CapToken,
80    spawner: Arc<dyn SpawnerAdapter>,
81    run_ctx: Option<RunContext>,
82    step_metas: HashMap<String, Value>,
83}
84
85impl EngineDispatcher {
86    /// Build a dispatcher with no run-level tracing (`run_ctx = None`) and
87    /// no named `MetaDef`s (`step_metas` empty) — the pre-existing
88    /// behavior. Use [`Self::with_run`] / [`Self::with_step_metas`] to
89    /// opt into either.
90    pub fn with_spawner(
91        engine: Engine,
92        op_token: CapToken,
93        spawner: Arc<dyn SpawnerAdapter>,
94    ) -> Self {
95        Self {
96            engine,
97            op_token,
98            spawner,
99            run_ctx: None,
100            step_metas: HashMap::new(),
101        }
102    }
103
104    /// Attach a [`RunContext`] (builder style) so every dispatched step is
105    /// traced into `RunRecord.step_entries` and exposes its `run_id` via
106    /// `Ctx.meta.runtime`.
107    pub fn with_run(mut self, run_ctx: RunContext) -> Self {
108        self.run_ctx = Some(run_ctx);
109        self
110    }
111
112    /// GH #21 Phase 2: attach the named `MetaDef` pool (`Blueprint.metas`,
113    /// resolved by `service::task_launch::derive_step_metas` into a
114    /// `name -> ctx` map) that [`Self::dispatch`] resolves `$step_meta.ref`
115    /// envelopes against. Unconditional to call — an empty map (the
116    /// pre-#21-Phase-2 default) makes every `$step_meta.ref` lookup miss
117    /// loudly, same as a Blueprint that never declares `Blueprint.metas`.
118    pub fn with_step_metas(mut self, step_metas: HashMap<String, Value>) -> Self {
119        self.step_metas = step_metas;
120        self
121    }
122}
123
124/// GH #21 Phase 2: resolve a `$step_meta` envelope embedded in a Step's
125/// evaluated `in` value into `(initial_directive, step_ctx)` — the Step
126/// tier's dispatch-time entry point, called from [`EngineDispatcher::dispatch`]
127/// BEFORE `Engine::start_task` (critical: `start_task` seeds
128/// `EngineState.prompts[(tid, 1)]` from `TaskSpec.initial_directive`, so
129/// stripping the envelope any later would leak `$step_meta` into the
130/// worker prompt AND the WS `Spawn.directive` text).
131///
132/// Contract:
133///
134/// - `input` is not a JSON `Object`, or is an `Object` with no
135///   `"$step_meta"` key → passthrough unchanged, `step_ctx = None`
136///   (pre-#21-Phase-2 Blueprints are byte-identical through this path).
137/// - `input` IS an `Object` with a `"$step_meta"` key: the key is always
138///   stripped (never reaches the returned directive). Everything past
139///   this point is loud — an error names the offending step (`ref_`) and,
140///   for an unresolved `ref`, the defined `step_metas` names:
141///   - the envelope itself must be an `Object` shaped
142///     `{"ref": Option<String>, "inline": Option<Object>}`; any other
143///     shape is a malformed-envelope error;
144///   - `ref` (when present and non-null) is looked up in `step_metas`; an
145///     unknown name is an error (no silent skip). The resolved `MetaDef`
146///     ctx must itself be an `Object` (or the lookup is treated as
147///     malformed);
148///   - `inline` (when present and non-null) must be an `Object`;
149///   - the resolved Step-tier ctx = the `ref`-resolved ctx shallow-merged
150///     with `inline`, **`inline` wins** key collisions.
151/// - Directive rule (applied to the remaining `Object`, after
152///   `"$step_meta"` is stripped): if it still contains an `"$in"` key,
153///   that value becomes the returned directive (other sibling keys are
154///   ignored for the directive — envelope-only input, e.g. one final
155///   `$step_meta` key, therefore never becomes an empty directive by
156///   accident just because more keys existed alongside it). Otherwise
157///   the whole remainder becomes the directive; an empty remainder
158///   becomes `Value::String(String::new())`.
159fn resolve_step_envelope(
160    step_metas: &HashMap<String, Value>,
161    ref_: &str,
162    input: Value,
163) -> Result<(Value, Option<Value>), EvalError> {
164    let mut obj = match input {
165        Value::Object(obj) => obj,
166        other => return Ok((other, None)),
167    };
168    let Some(envelope) = obj.remove("$step_meta") else {
169        return Ok((Value::Object(obj), None));
170    };
171    let envelope = match envelope {
172        Value::Object(map) => map,
173        other => {
174            return Err(EvalError::DispatcherError {
175                ref_: ref_.to_string(),
176                msg: format!(
177                    "malformed $step_meta envelope for step '{ref_}': expected an object, got {other}"
178                ),
179            });
180        }
181    };
182
183    let ref_ctx: Option<Map<String, Value>> = match envelope.get("ref") {
184        None | Some(Value::Null) => None,
185        Some(Value::String(name)) => {
186            let resolved = step_metas.get(name).cloned().ok_or_else(|| {
187                EvalError::DispatcherError {
188                    ref_: ref_.to_string(),
189                    msg: format!(
190                        "$step_meta.ref '{name}' (step '{ref_}') is not a defined Blueprint.metas entry (defined: {:?})",
191                        step_metas.keys().collect::<Vec<_>>()
192                    ),
193                }
194            })?;
195            match resolved {
196                Value::Object(map) => Some(map),
197                other => {
198                    return Err(EvalError::DispatcherError {
199                        ref_: ref_.to_string(),
200                        msg: format!(
201                            "malformed $step_meta: MetaDef '{name}'.ctx must be an object, got {other}"
202                        ),
203                    });
204                }
205            }
206        }
207        Some(other) => {
208            return Err(EvalError::DispatcherError {
209                ref_: ref_.to_string(),
210                msg: format!(
211                    "malformed $step_meta.ref (step '{ref_}'): expected a string, got {other}"
212                ),
213            });
214        }
215    };
216
217    let inline: Option<Map<String, Value>> = match envelope.get("inline") {
218        None | Some(Value::Null) => None,
219        Some(Value::Object(map)) => Some(map.clone()),
220        Some(other) => {
221            return Err(EvalError::DispatcherError {
222                ref_: ref_.to_string(),
223                msg: format!(
224                    "malformed $step_meta.inline (step '{ref_}'): expected an object, got {other}"
225                ),
226            });
227        }
228    };
229
230    let step_ctx = match (ref_ctx, inline) {
231        (None, None) => None,
232        (Some(base), None) => Some(Value::Object(base)),
233        (None, Some(inline)) => Some(Value::Object(inline)),
234        (Some(mut base), Some(inline)) => {
235            for (k, v) in inline {
236                base.insert(k, v);
237            }
238            Some(Value::Object(base))
239        }
240    };
241
242    // Directive rule — only reached once a `$step_meta` envelope was
243    // present in `input`.
244    let initial_directive = if let Some(in_value) = obj.remove("$in") {
245        in_value
246    } else if obj.is_empty() {
247        Value::String(String::new())
248    } else {
249        Value::Object(obj)
250    };
251
252    Ok((initial_directive, step_ctx))
253}
254
255#[async_trait]
256impl AsyncDispatcher for EngineDispatcher {
257    async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
258        // issue #18: the evaluated Step.in value passes straight through
259        // as `TaskSpec.initial_directive` — no premature `Value → String`
260        // coercion here. Consumers that need a rendered `String` do so at
261        // their own late boundary: `Engine::start_task` /
262        // `Engine::dispatch_attempt_with` render it into the
263        // `EngineState.prompts` table for the Worker HTTP path
264        // (`/v1/worker/prompt`), and
265        // `operator_ws::session::default_spawn_directive_with_task_directive`
266        // renders it into the WS `Spawn.directive` reminder text.
267        //
268        // GH #21 Phase 2: BEFORE that pass-through, resolve_step_envelope
269        // strips + resolves any `$step_meta` envelope — see its doc for
270        // the full contract. Inputs without one flow through unchanged.
271        let (initial_directive, step_ctx) = resolve_step_envelope(&self.step_metas, ref_, input)?;
272        let tid = self
273            .engine
274            .start_task(
275                &self.op_token,
276                TaskSpec {
277                    agent: ref_.to_string(),
278                    initial_directive,
279                    step_ctx,
280                },
281            )
282            .await
283            .map_err(|e| EvalError::DispatcherError {
284                ref_: ref_.to_string(),
285                msg: format!("start_task: {e}"),
286            })?;
287
288        let run_id_for_ctx = self.run_ctx.as_ref().map(|rc| rc.run_id.clone());
289        let outcome = self
290            .engine
291            .dispatch_attempt_with(&self.op_token, &tid, &self.spawner, run_id_for_ctx.as_ref())
292            .await;
293
294        // issue #13 run_id propagation: append one step_entry per dispatched
295        // step (`RunStore.append_step_entry` is append-only — there is no
296        // in-place update — so the entry is written once here, after the
297        // outcome is known, carrying its final status). Secondary
298        // persistence failures are logged and swallowed, matching
299        // `mse-server`'s `finalize_run` convention: they must not mask the
300        // primary dispatch outcome the flow eval already has in hand.
301        if let Some(rc) = &self.run_ctx {
302            let status = match &outcome {
303                Ok(DispatchOutcome::Pass(_)) => "passed",
304                Ok(DispatchOutcome::Blocked(_)) => "blocked",
305                Ok(DispatchOutcome::Suspended(_)) => "suspended",
306                Ok(DispatchOutcome::Cancelled) => "cancelled",
307                Ok(DispatchOutcome::Timeout) => "timeout",
308                Err(_) => "failed",
309            };
310            let entry = StepEntry {
311                step_id: tid.clone(),
312                step_ref: Some(ref_.to_string()),
313                status: Some(status.to_string()),
314                at: now_unix(),
315            };
316            if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
317                tracing::warn!(
318                    run_id = %rc.run_id,
319                    step_id = %tid,
320                    error = %e,
321                    "EngineDispatcher::dispatch: append_step_entry failed"
322                );
323            }
324        }
325
326        match outcome {
327            Ok(DispatchOutcome::Pass(v)) => Ok(v),
328            Ok(DispatchOutcome::Blocked(v)) => Err(EvalError::DispatcherError {
329                ref_: ref_.to_string(),
330                msg: format!("blocked: {v}"),
331            }),
332            Ok(other) => Err(EvalError::DispatcherError {
333                ref_: ref_.to_string(),
334                msg: format!("non-terminal outcome: {:?}", other),
335            }),
336            Err(e) => Err(EvalError::DispatcherError {
337                ref_: ref_.to_string(),
338                msg: format!("dispatch_attempt: {e}"),
339            }),
340        }
341    }
342}
343
344// ──────────────────────────────────────────────────────────────────────────
345// issue #21 Phase 2: `resolve_step_envelope` unit tests + a dispatch-level
346// end-to-end leak-proof test
347// ──────────────────────────────────────────────────────────────────────────
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    fn metas(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
355        pairs
356            .iter()
357            .map(|(k, v)| (k.to_string(), v.clone()))
358            .collect()
359    }
360
361    #[test]
362    fn no_envelope_string_input_passes_through_unchanged() {
363        let (directive, step_ctx) =
364            resolve_step_envelope(&HashMap::new(), "scout", json!("plain string")).unwrap();
365        assert_eq!(directive, json!("plain string"));
366        assert_eq!(step_ctx, None);
367    }
368
369    #[test]
370    fn no_envelope_plain_object_input_passes_through_unchanged() {
371        let input = json!({ "foo": "bar" });
372        let (directive, step_ctx) =
373            resolve_step_envelope(&HashMap::new(), "scout", input.clone()).unwrap();
374        assert_eq!(directive, input);
375        assert_eq!(step_ctx, None);
376    }
377
378    #[test]
379    fn envelope_with_only_ref_resolves_that_metadef_ctx() {
380        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
381        let input = json!({ "$step_meta": { "ref": "heavy-scan" }, "$in": "go" });
382        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
383        assert_eq!(directive, json!("go"));
384        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
385    }
386
387    #[test]
388    fn envelope_with_only_inline_uses_inline_verbatim() {
389        let input = json!({
390            "$step_meta": { "inline": { "work_dir": "/inline-only" } },
391            "$in": "go"
392        });
393        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
394        assert_eq!(directive, json!("go"));
395        assert_eq!(step_ctx, Some(json!({ "work_dir": "/inline-only" })));
396    }
397
398    #[test]
399    fn inline_wins_over_ref_on_key_collision() {
400        let step_metas = metas(&[(
401            "heavy-scan",
402            json!({ "work_dir": "/ref", "extra": "from-ref" }),
403        )]);
404        let input = json!({
405            "$step_meta": {
406                "ref": "heavy-scan",
407                "inline": { "work_dir": "/inline-wins" }
408            },
409            "$in": "go"
410        });
411        let (_, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
412        assert_eq!(
413            step_ctx,
414            Some(json!({ "work_dir": "/inline-wins", "extra": "from-ref" })),
415            "inline must win the collided key while ref-only keys survive the merge"
416        );
417    }
418
419    #[test]
420    fn dollar_in_rule_extracts_directive_and_ignores_other_sibling_keys() {
421        let input = json!({
422            "$step_meta": { "inline": { "k": "v" } },
423            "$in": "the real directive",
424            "unrelated_sibling": "ignored"
425        });
426        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
427        assert_eq!(directive, json!("the real directive"));
428        assert_eq!(step_ctx, Some(json!({ "k": "v" })));
429    }
430
431    #[test]
432    fn no_dollar_in_remainder_becomes_the_directive() {
433        let input = json!({
434            "$step_meta": { "inline": { "k": "v" } },
435            "other_key": "other_value"
436        });
437        let (directive, _) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
438        assert_eq!(directive, json!({ "other_key": "other_value" }));
439    }
440
441    #[test]
442    fn empty_remainder_becomes_empty_string_directive() {
443        let input = json!({ "$step_meta": { "ref": "heavy-scan" } });
444        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
445        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
446        assert_eq!(directive, Value::String(String::new()));
447        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
448    }
449
450    #[test]
451    fn unresolved_ref_is_a_loud_dispatcher_error_naming_ref_and_defined() {
452        let step_metas = metas(&[("known", json!({}))]);
453        let input = json!({ "$step_meta": { "ref": "unknown" }, "$in": "go" });
454        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
455        match err {
456            EvalError::DispatcherError { ref_, msg } => {
457                assert_eq!(ref_, "scout");
458                assert!(
459                    msg.contains("unknown"),
460                    "message must name the unresolved ref: {msg}"
461                );
462                assert!(
463                    msg.contains("known"),
464                    "message must list defined names: {msg}"
465                );
466            }
467            other => panic!("expected DispatcherError, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn malformed_step_meta_not_an_object_is_a_loud_error() {
473        let input = json!({ "$step_meta": "not-an-object" });
474        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
475        assert!(matches!(err, EvalError::DispatcherError { .. }));
476    }
477
478    #[test]
479    fn malformed_ref_non_string_is_a_loud_error() {
480        let input = json!({ "$step_meta": { "ref": 42 } });
481        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
482        assert!(matches!(err, EvalError::DispatcherError { .. }));
483    }
484
485    #[test]
486    fn malformed_inline_non_object_is_a_loud_error() {
487        let input = json!({ "$step_meta": { "inline": "not-an-object" } });
488        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
489        assert!(matches!(err, EvalError::DispatcherError { .. }));
490    }
491
492    #[test]
493    fn ref_resolved_metadef_ctx_non_object_is_a_loud_error() {
494        let step_metas = metas(&[("bad", json!("not-an-object"))]);
495        let input = json!({ "$step_meta": { "ref": "bad" } });
496        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
497        assert!(matches!(err, EvalError::DispatcherError { .. }));
498    }
499
500    /// End-to-end proof (issue #21 Phase 2 Done Criteria #5): a `$step_meta`
501    /// envelope must never reach `EngineState.prompts[(tid, 1)]` — the
502    /// resolve step runs BEFORE `start_task` seeds that table.
503    #[tokio::test]
504    async fn dispatch_step_meta_envelope_never_leaks_into_stored_prompt() {
505        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
506        use crate::core::config::EngineCfg;
507        use crate::types::{Role, StepId};
508        use crate::worker::adapter::WorkerResult;
509        use std::sync::Mutex as StdMutex;
510        use std::time::Duration;
511
512        let captured_tid: Arc<StdMutex<Option<StepId>>> = Arc::new(StdMutex::new(None));
513        let captured_tid_for_fn = captured_tid.clone();
514        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
515            let captured_tid = captured_tid_for_fn.clone();
516            async move {
517                *captured_tid.lock().unwrap() = Some(inv.task_id.clone());
518                Ok(WorkerResult {
519                    value: json!({ "ok": true }),
520                    ok: true,
521                })
522            }
523        });
524        let def = AgentDef {
525            name: "scout".into(),
526            kind: AgentKind::RustFn,
527            spec: json!({ "fn_id": "echo" }),
528            profile: None,
529            meta: None,
530        };
531        let spawner = factory.build(&def, None).expect("build");
532
533        let engine = Engine::new(EngineCfg::default());
534        let token = engine
535            .attach("ut-op", Role::Operator, Duration::from_secs(30))
536            .await
537            .expect("attach");
538        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
539        let dispatcher = EngineDispatcher::with_spawner(engine.clone(), token, spawner)
540            .with_step_metas(step_metas);
541
542        let input = json!({
543            "$step_meta": { "ref": "heavy-scan" },
544            "$in": "do the thing"
545        });
546        let out = dispatcher
547            .dispatch("scout", input)
548            .await
549            .expect("dispatch ok");
550        assert_eq!(out, json!({ "ok": true }));
551
552        let tid = captured_tid
553            .lock()
554            .unwrap()
555            .clone()
556            .expect("task_id captured");
557        let stored_prompt = engine
558            .with_state("test.read_prompt", move |s| {
559                s.prompts.get(&(tid, 1)).cloned()
560            })
561            .await
562            .expect("with_state")
563            .expect("prompt recorded for attempt 1");
564        assert_eq!(
565            stored_prompt,
566            json!("do the thing"),
567            "the stored prompt must be the post-envelope directive, with no $step_meta leakage"
568        );
569    }
570}