Skip to main content

mlua_swarm/middleware/
worker_binding.rs

1//! `WorkerBindingMiddleware` — a `SpawnerLayer` that propagates the
2//! Blueprint-baked per-agent [`WorkerBinding`] through `Ctx.meta.runtime`.
3//!
4//! `service::task_launch` builds an `agent name → WorkerBinding` map from
5//! `Blueprint.agents[].profile.worker_binding` at launch time and places
6//! this layer on the stack (outermost, next to `ProjectNameAliasMiddleware`).
7//! Just before spawn it looks the map up by `ctx.agent` and, on a hit,
8//! inserts the serialized binding into `Ctx.meta.runtime` under the
9//! `worker_binding` key.
10//!
11//! Agents with no declared binding get no entry; the WS thin-path
12//! `requires_worker_binding` fail-loud stays the safety net.
13//!
14//! # What reads the key: nothing in this repository
15//!
16//! Said plainly, because a reader deciding whether to keep this layer on
17//! their stack needs it before the rationale. This layer was introduced
18//! *for* the delegate axis (`b5eb000`, "wire worker binding through the
19//! delegate axis"): `OperatorDelegateMiddleware` had no per-agent
20//! `OperatorSpawner` to carry a compile-time-baked binding, so it read the
21//! binding back out of `Ctx.meta.runtime` at spawn. That axis is gone, and
22//! the AgentSpec axis needs no such round trip — `OperatorSpawner` holds
23//! the baked `WorkerBinding` and hands it to `Operator::execute` as an
24//! argument.
25//!
26//! With that reader deleted, a repo-wide search for [`WORKER_BINDING_KEY`]
27//! and for `ctx.meta.runtime`'s `"worker_binding"` entry finds this
28//! module's own tests and nothing else. In particular
29//! `AgentContextView::from_ctx` does not read it (it reads the project /
30//! work-dir / metadata / run-id / alias keys), and `mlua-swarm-server`'s
31//! WS session takes the binding from its `execute` argument.
32//!
33//! Two things the layer still does, and they are the whole of why it stays
34//! wired:
35//!
36//! - It is a **published extension point**. This type and the key are
37//!   `pub` on a library crate, and "downstream Operator / Spawner code
38//!   reads the injected keys back via `ctx.meta.runtime.get(...)`" is this
39//!   crate's stated convention for out-of-tree implementations —
40//!   [`crate::middleware::task_input`]'s module doc names `worker_binding`
41//!   as one of those keys. Having no in-tree reader is not the same as
42//!   having no reader.
43//! - The value lands in the `Ctx` snapshot `ReplayStore` persists for each
44//!   passed step, so a stored run records the binding every spawn
45//!   declared.
46//!
47//! Neither is load-bearing, and this doc does not pretend otherwise. If
48//! the extension point is not wanted, delete the layer, its conditional
49//! wiring in `service::task_launch::TaskLaunchService::launch`, and this
50//! key — rather than leaving a claim of a live reader standing. That is a
51//! breaking change (both items are `pub`), and it also needs the prose
52//! references in [`crate::middleware::task_input`],
53//! `crate::middleware::agent_context` and `crate::core::agent_context`
54//! updated, one of which is an intra-doc link that would otherwise fail
55//! the doc build.
56//!
57//! Same shape as `ProjectNameAliasMiddleware` / `CompiledAgentTable`: a
58//! compile/launch-time table keyed by agent name, looked up via
59//! `ctx.agent` at spawn time, no engine state touched.
60
61use crate::core::ctx::Ctx;
62use crate::core::engine::Engine;
63use crate::middleware::SpawnerLayer;
64use crate::operator::WorkerBinding;
65use crate::types::{CapToken, StepId};
66use crate::worker::adapter::{SpawnError, SpawnerAdapter};
67use crate::worker::Worker;
68use async_trait::async_trait;
69use std::collections::HashMap;
70use std::sync::Arc;
71
72/// Key under `ctx.meta.runtime` this layer writes the serialized
73/// [`WorkerBinding`] to.
74///
75/// `pub` because reading it back *is* the extension point. The delegate
76/// axis used to be the in-tree reader; see the module doc for what is left
77/// after its removal (nothing in this repository).
78pub const WORKER_BINDING_KEY: &str = "worker_binding";
79
80/// `SpawnerLayer` that drops the per-agent binding into `ctx` just before
81/// spawn.
82pub struct WorkerBindingMiddleware {
83    bindings: Arc<HashMap<String, WorkerBinding>>,
84}
85
86impl WorkerBindingMiddleware {
87    /// Wraps an `agent name → WorkerBinding` map to inject on every spawn
88    /// whose `ctx.agent` has an entry.
89    pub fn new(bindings: HashMap<String, WorkerBinding>) -> Self {
90        Self {
91            bindings: Arc::new(bindings),
92        }
93    }
94}
95
96impl SpawnerLayer for WorkerBindingMiddleware {
97    fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
98        Arc::new(WorkerBindingWrapped {
99            inner,
100            bindings: self.bindings.clone(),
101        })
102    }
103}
104
105struct WorkerBindingWrapped {
106    inner: Arc<dyn SpawnerAdapter>,
107    bindings: Arc<HashMap<String, WorkerBinding>>,
108}
109
110#[async_trait]
111impl SpawnerAdapter for WorkerBindingWrapped {
112    async fn spawn(
113        &self,
114        engine: &Engine,
115        ctx: &Ctx,
116        task_id: StepId,
117        attempt: u32,
118        token: CapToken,
119    ) -> Result<Box<dyn Worker>, SpawnError> {
120        let Some(binding) = self.bindings.get(&ctx.agent) else {
121            // No declared binding for this agent — pass through untouched;
122            // binding-requiring backends fail loud downstream.
123            return self.inner.spawn(engine, ctx, task_id, attempt, token).await;
124        };
125        let value = serde_json::to_value(binding).map_err(|e| {
126            SpawnError::Internal(format!(
127                "worker_binding for agent '{}' failed to serialize: {e}",
128                ctx.agent
129            ))
130        })?;
131        let mut new_ctx = ctx.clone();
132        new_ctx
133            .meta
134            .runtime
135            .insert(WORKER_BINDING_KEY.to_string(), value);
136        self.inner
137            .spawn(engine, &new_ctx, task_id, attempt, token)
138            .await
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::core::config::EngineCfg;
146    use crate::types::Role;
147    use std::sync::Mutex;
148    use std::time::Duration;
149
150    /// Inner spawner stub that records the `Ctx` it was called with and
151    /// fails the spawn (we only care about the ctx snapshot).
152    struct CtxProbe {
153        seen: Arc<Mutex<Option<Ctx>>>,
154    }
155
156    #[async_trait]
157    impl SpawnerAdapter for CtxProbe {
158        async fn spawn(
159            &self,
160            _engine: &Engine,
161            ctx: &Ctx,
162            _task_id: StepId,
163            _attempt: u32,
164            _token: CapToken,
165        ) -> Result<Box<dyn Worker>, SpawnError> {
166            *self.seen.lock().unwrap() = Some(ctx.clone());
167            Err(SpawnError::Internal("probe stop".into()))
168        }
169    }
170
171    fn probe_stack(
172        bindings: HashMap<String, WorkerBinding>,
173    ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
174        let seen = Arc::new(Mutex::new(None));
175        let inner = Arc::new(CtxProbe { seen: seen.clone() });
176        let wrapped = WorkerBindingMiddleware::new(bindings).wrap(inner);
177        (wrapped, seen)
178    }
179
180    #[tokio::test]
181    async fn injects_binding_into_ctx_meta_runtime_on_hit() {
182        let mut map = HashMap::new();
183        map.insert(
184            "planner".to_string(),
185            WorkerBinding {
186                variant: "knowledge-worker".to_string(),
187                tools: vec!["Read".to_string()],
188                request_digest: Some(
189                    "sha256:1111111111111111111111111111111111111111111111111111111111111111"
190                        .parse()
191                        .unwrap(),
192                ),
193                requested_model: Some("claude-sonnet".to_string()),
194            },
195        );
196        let (stack, seen) = probe_stack(map);
197        let engine = Engine::new(EngineCfg::default());
198        let task_id = StepId::parse("ST-1").unwrap();
199        let ctx = Ctx::new(task_id.clone(), 1, "planner");
200        let token = engine
201            .attach("ut-op", Role::Operator, Duration::from_secs(30))
202            .await
203            .expect("attach");
204        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
205
206        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
207        let v = observed
208            .meta
209            .runtime
210            .get(WORKER_BINDING_KEY)
211            .expect("worker_binding key present");
212        let wb: WorkerBinding = serde_json::from_value(v.clone()).expect("round-trip");
213        assert_eq!(wb.variant, "knowledge-worker");
214        assert_eq!(wb.tools, vec!["Read".to_string()]);
215        // The requesting side's self-check inputs survive the ctx.meta.runtime
216        // round-trip so the Operator can correlate and compare its environment.
217        assert!(wb
218            .request_digest
219            .as_ref()
220            .expect("request_digest present")
221            .as_str()
222            .starts_with("sha256:"));
223        assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
224    }
225
226    /// Wire compatibility: a binding with both self-check fields `None` omits
227    /// the keys entirely, so the serialized frame matches the pre-C2 shape.
228    #[test]
229    fn omits_self_check_fields_when_none() {
230        let json = serde_json::to_value(WorkerBinding {
231            variant: "v".to_string(),
232            tools: vec![],
233            request_digest: None,
234            requested_model: None,
235        })
236        .expect("serialize");
237        let obj = json.as_object().expect("object");
238        assert!(!obj.contains_key("request_digest"));
239        assert!(!obj.contains_key("requested_model"));
240    }
241
242    #[tokio::test]
243    async fn passes_through_untouched_on_miss() {
244        let (stack, seen) = probe_stack(HashMap::new());
245        let engine = Engine::new(EngineCfg::default());
246        let task_id = StepId::parse("ST-2").unwrap();
247        let ctx = Ctx::new(task_id.clone(), 1, "unbound-agent");
248        let token = engine
249            .attach("ut-op", Role::Operator, Duration::from_secs(30))
250            .await
251            .expect("attach");
252        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
253
254        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
255        assert!(
256            !observed.meta.runtime.contains_key(WORKER_BINDING_KEY),
257            "no binding entry must be injected on miss"
258        );
259    }
260}