Skip to main content

mlua_swarm/core/
ctx.rs

1//! `Ctx` and `OperatorInfo` — cross-cutting context threaded through the
2//! engine.
3//!
4//! The main pipeline (Engine → `SpawnerAdapter` → `WorkerAdapter`) does not
5//! know about Operators. Middleware watches `Ctx.operator` and branches on
6//! it.
7
8use crate::types::StepId;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15/// Per-attempt context threaded through the engine and into worker/spawner
16/// code. Carries identity (`task_id` / `attempt` / `agent`), free-form
17/// metadata (`meta`), and the resolved `Operator` faces (`operator`).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Ctx {
20    /// The task this attempt belongs to.
21    pub task_id: StepId,
22    /// 1-based attempt counter for `task_id` (bumped by
23    /// `Engine::dispatch_attempt_with` on every dispatch).
24    pub attempt: u32,
25    /// Name of the agent being dispatched (`TaskSpec.agent`).
26    pub agent: String,
27    /// Free-form namespaced metadata (runtime / authz / observer / loop).
28    pub meta: CtxMeta,
29    /// The Operator faces resolved for this attempt. Not serialized —
30    /// `Arc<dyn ...>` trait objects have no stable on-wire form; only the
31    /// IDs (persisted on `LaunchEnvelope`) survive a restart.
32    #[serde(skip)]
33    pub operator: OperatorInfo,
34}
35
36impl Ctx {
37    /// Build a fresh `Ctx` with default `meta` and `operator`
38    /// (`OperatorInfo::default()`, i.e. `Automate` / no bridges).
39    pub fn new(task_id: StepId, attempt: u32, agent: impl Into<String>) -> Self {
40        Self {
41            task_id,
42            attempt,
43            agent: agent.into(),
44            meta: CtxMeta::default(),
45            operator: OperatorInfo::default(),
46        }
47    }
48}
49
50/// Namespaced free-form key/value bags attached to a `Ctx`. Each namespace
51/// is a convention, not an enforced schema — e.g. `runtime` carries
52/// per-dispatch values like `worker_handle`.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct CtxMeta {
55    /// Values set by the engine/spawner at dispatch time (e.g.
56    /// `worker_handle`, `spawn_depth`).
57    #[serde(default)]
58    pub runtime: HashMap<String, Value>,
59    /// Values relevant to authorization/role decisions.
60    #[serde(default)]
61    pub authz: HashMap<String, Value>,
62    /// Values relevant to observers/tracing.
63    #[serde(default)]
64    pub observer: HashMap<String, Value>,
65    /// Values relevant to loop/iteration bookkeeping.
66    #[serde(default)]
67    pub loop_ns: HashMap<String, Value>,
68}
69
70/// Who/what is driving a spawn: a plain automated worker, an interactive
71/// MainAI operator, or a composite of both. Gates `MainAIMiddleware` — the
72/// only layer that reads this value — and feeds the 4-tier cascade
73/// resolved by [`collapse_operator_kind`]. See "The role of `kind`" in the
74/// [`OperatorInfo`] doc below.
75///
76/// It used to gate `OperatorDelegateMiddleware` as well. That layer is
77/// gone (it resolved its destination from the launch record rather than
78/// the Run's seat), so `kind` no longer decides whether a dispatch reaches
79/// an `Operator` at all — the agent's own `kind = Operator` does.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum OperatorKind {
83    /// An interactive, single-Operator-driven session: `spawn_hook`'s
84    /// `before` / `after` fire around every spawn. (Full-spawn delegation
85    /// was the other thing this value enabled; that axis no longer
86    /// exists.)
87    MainAi,
88    /// A plain automated worker; middleware passes through into a normal
89    /// spawn (the default).
90    #[default]
91    Automate,
92    /// A mixed mode combining automated and MainAi-driven behavior (same
93    /// gating as `MainAi` for middleware purposes).
94    Composite,
95}
96
97impl From<mlua_swarm_schema::OperatorKind> for OperatorKind {
98    fn from(k: mlua_swarm_schema::OperatorKind) -> Self {
99        match k {
100            mlua_swarm_schema::OperatorKind::MainAi => OperatorKind::MainAi,
101            mlua_swarm_schema::OperatorKind::Automate => OperatorKind::Automate,
102            mlua_swarm_schema::OperatorKind::Composite => OperatorKind::Composite,
103        }
104    }
105}
106
107/// The single canonical implementation of the 4-tier `OperatorKind` cascade
108/// (schema doc: `mlua_swarm_schema::Blueprint::default_operator_kind`).
109///
110/// Each tier is optional; the first `Some` wins, top to bottom. All four
111/// absent falls back to `OperatorKind::default()` (Automate).
112///
113/// | tier | meaning |
114/// |---|---|
115/// | `runtime_agent` | per-agent override supplied at task-launch time (narrowest, most direct) |
116/// | `runtime_global` | the launch-time `operator_kind` request (session-wide) |
117/// | `bp_agent` | `OperatorDef.kind`, resolved per-agent via `AgentDef.spec.operator_ref` |
118/// | `bp_global` | `Blueprint.default_operator_kind` |
119///
120/// Consumed by `Engine::resolve_operator_info` (`crate::core::engine`), which
121/// supplies `runtime_agent` / `bp_agent` from per-agent `HashMap` lookups on
122/// `LaunchEnvelope`, and `runtime_global` / `bp_global` from session-level
123/// fields — `runtime_global` is `LaunchEnvelope.operator_kind` verbatim
124/// (an `Option<OperatorKind>`; `Some(_)` is always an explicit request,
125/// including `Some(Automate)`, and `None` means unspecified).
126pub fn collapse_operator_kind(
127    runtime_agent: Option<OperatorKind>,
128    runtime_global: Option<OperatorKind>,
129    bp_agent: Option<OperatorKind>,
130    bp_global: Option<OperatorKind>,
131) -> OperatorKind {
132    runtime_agent
133        .or(runtime_global)
134        .or(bp_agent)
135        .or(bp_global)
136        .unwrap_or_default()
137}
138
139#[cfg(test)]
140mod collapse_operator_kind_tests {
141    use super::*;
142
143    // (i) All tiers None → Default Fallback (Automate).
144    #[test]
145    fn all_none_falls_back_to_automate() {
146        assert_eq!(
147            collapse_operator_kind(None, None, None, None),
148            OperatorKind::Automate
149        );
150    }
151
152    // (ii) BP Global alone → BP Global value.
153    #[test]
154    fn bp_global_only_wins() {
155        assert_eq!(
156            collapse_operator_kind(None, None, None, Some(OperatorKind::MainAi)),
157            OperatorKind::MainAi
158        );
159    }
160
161    // (iii) BP Agent alone → BP Agent value.
162    #[test]
163    fn bp_agent_only_wins() {
164        assert_eq!(
165            collapse_operator_kind(None, None, Some(OperatorKind::MainAi), None),
166            OperatorKind::MainAi
167        );
168    }
169
170    // (iv) Runtime Global alone → Runtime Global value.
171    #[test]
172    fn runtime_global_only_wins() {
173        assert_eq!(
174            collapse_operator_kind(None, Some(OperatorKind::MainAi), None, None),
175            OperatorKind::MainAi
176        );
177    }
178
179    // (v) Runtime Agent alone → Runtime Agent value.
180    #[test]
181    fn runtime_agent_only_wins() {
182        assert_eq!(
183            collapse_operator_kind(Some(OperatorKind::MainAi), None, None, None),
184            OperatorKind::MainAi
185        );
186    }
187
188    // (vi) All tiers set → Runtime Agent value (narrow-wins check).
189    #[test]
190    fn all_tiers_set_runtime_agent_wins() {
191        assert_eq!(
192            collapse_operator_kind(
193                Some(OperatorKind::MainAi),
194                Some(OperatorKind::Composite),
195                Some(OperatorKind::Automate),
196                Some(OperatorKind::Composite),
197            ),
198            OperatorKind::MainAi
199        );
200    }
201
202    // (vii) BP Agent + Runtime Global together → Runtime Global (later-wins check).
203    #[test]
204    fn runtime_global_beats_bp_agent() {
205        assert_eq!(
206            collapse_operator_kind(
207                None,
208                Some(OperatorKind::Composite),
209                Some(OperatorKind::MainAi),
210                None,
211            ),
212            OperatorKind::Composite
213        );
214    }
215
216    // null merge: Runtime Agent-level unset for this agent but BP Agent set,
217    // BP Global also set → BP Agent (narrower) wins over BP Global.
218    #[test]
219    fn bp_agent_beats_bp_global_when_runtime_tiers_absent() {
220        assert_eq!(
221            collapse_operator_kind(
222                None,
223                None,
224                Some(OperatorKind::MainAi),
225                Some(OperatorKind::Composite),
226            ),
227            OperatorKind::MainAi
228        );
229    }
230
231    #[test]
232    fn schema_operator_kind_converts_into_ctx_operator_kind() {
233        assert_eq!(
234            OperatorKind::from(mlua_swarm_schema::OperatorKind::MainAi),
235            OperatorKind::MainAi
236        );
237        assert_eq!(
238            OperatorKind::from(mlua_swarm_schema::OperatorKind::Automate),
239            OperatorKind::Automate
240        );
241        assert_eq!(
242            OperatorKind::from(mlua_swarm_schema::OperatorKind::Composite),
243            OperatorKind::Composite
244        );
245    }
246}
247
248/// The bundle of Operator faces the engine injects into `Ctx` at dispatch.
249///
250/// # The two `Arc<dyn ...>` fields — the Operator faces that intercept
251///
252/// Conceptually the Operator is one role, but inside the engine it fans out
253/// into interception axes that fire independently. The canonical use is one
254/// external Operator (say, a WebSocket client) that implements the traits
255/// and answers every axis from a single session (see a WebSocket-backed
256/// operator session in the server crate).
257///
258/// | field | trait | firing layer | purpose |
259/// |---|---|---|---|
260/// | `senior_bridge` | [`SeniorBridge`] | `SeniorEscalationMiddleware` | When a worker returns `ok = false`, query a judgment source and upgrade the outcome to Pass. |
261/// | `spawn_hook` | [`SpawnHook`] | `MainAIMiddleware` | Pre- and post-spawn observation and approve/reject gating (`kind = MainAi` / `Composite` only). |
262///
263/// There used to be a third, `operator:
264/// Option<Arc<dyn crate::operator::Operator>>`, resolved from
265/// `LaunchEnvelope.operator_backend_id` and read by
266/// `OperatorDelegateMiddleware` to bypass the spawn entirely. Both are
267/// gone. It is worth being precise about what that does *not* mean:
268/// dispatching through an `Operator` is alive and well — it simply happens
269/// on the AgentSpec axis now, where `OperatorSpawner` holds the
270/// `Arc<dyn Operator>` for the agent's declared seat. What went is the
271/// *second*, session-global way of reaching one, which resolved its
272/// destination from the launch record instead of the Run's seat and so
273/// could not follow a handover. A dispatch's operator is no longer
274/// something the `Ctx` carries.
275///
276/// # The role of `kind`
277///
278/// Middleware uses `OperatorKind` (`Automate` / `MainAi` / `Composite`) as a
279/// gating signal: `MainAi` / `Composite` enable `spawn_hook`; `Automate`
280/// lets middleware pass through into a normal spawn. `senior_bridge` is
281/// kind-agnostic and fires whenever `ok = false`.
282///
283/// # Default
284///
285/// `OperatorKind::Automate` with both `Arc<dyn ...>` fields set to `None`.
286/// Middleware passes through; execution stays inline as usual.
287///
288/// # Persistence boundary
289///
290/// `OperatorInfo` is transient inside `Ctx` (`#[serde(skip)]`). The
291/// persisted `LaunchEnvelope` only holds IDs (`bridge_id` / `hook_id` /
292/// `operator_backend_id`). At dispatch time the engine resolves each `Arc`
293/// by looking those IDs up in its `senior_bridges` / `spawn_hooks`
294/// `HashMap`s via `resolve_operator_info(session) -> OperatorInfo`.
295/// `operator_backend_id` no longer resolves to anything here — it survives
296/// as the persisted session shape and as the launch-time key
297/// `Engine::list_operator_ids` validates an `operator_sid` against.
298#[derive(Clone)]
299pub struct OperatorInfo {
300    /// Gating signal consumed by middleware; see the "role of `kind`"
301    /// section above.
302    pub kind: OperatorKind,
303    /// Identifier of the attached Operator/session (`LaunchEnvelope.operator_id`).
304    pub id: String,
305    /// See the `senior_bridge` row in the table above.
306    pub senior_bridge: Option<Arc<dyn SeniorBridge>>,
307    /// See the `spawn_hook` row in the table above.
308    pub spawn_hook: Option<Arc<dyn SpawnHook>>,
309}
310
311impl std::fmt::Debug for OperatorInfo {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("OperatorInfo")
314            .field("kind", &self.kind)
315            .field("id", &self.id)
316            .field("senior_bridge", &self.senior_bridge.is_some())
317            .field("spawn_hook", &self.spawn_hook.is_some())
318            .finish()
319    }
320}
321
322impl Default for OperatorInfo {
323    fn default() -> Self {
324        Self {
325            kind: OperatorKind::Automate,
326            id: "default-automate".into(),
327            senior_bridge: None,
328            spawn_hook: None,
329        }
330    }
331}
332
333/// Escalation channel fired by `SeniorEscalationMiddleware` whenever a
334/// worker returns `ok = false`: a chance for a "senior" judgment source to
335/// review and potentially upgrade the outcome to Pass.
336#[async_trait]
337pub trait SeniorBridge: Send + Sync {
338    /// Ask the Senior a question and wait for the answer (`Value`). The
339    /// implementation is free — a CLI prompt, an MCP modal, another
340    /// process, whatever.
341    async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String>;
342}
343
344/// Pre-/post-spawn observation and gating hook fired by
345/// `MainAIMiddleware` (only when `OperatorKind` is `MainAi` / `Composite`).
346#[async_trait]
347pub trait SpawnHook: Send + Sync {
348    /// Hook fired **before** the spawn. Returning `Err` aborts the spawn.
349    async fn before(&self, ctx: &Ctx) -> Result<(), String>;
350    /// Hook fired **after** the spawn (once the worker has finished).
351    async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String>;
352}