1use 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
42pub 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
55pub 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 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 pub fn with_run(mut self, run_ctx: RunContext) -> Self {
108 self.run_ctx = Some(run_ctx);
109 self
110 }
111
112 pub fn with_step_metas(mut self, step_metas: HashMap<String, Value>) -> Self {
119 self.step_metas = step_metas;
120 self
121 }
122}
123
124fn 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 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 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 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#[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 #[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}