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::TaskId;
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: TaskId,
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 `OperatorSession`) 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: TaskId, 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` /
72/// `OperatorDelegateMiddleware` (see `OperatorInfo` doc below) and feeds
73/// the 4-tier cascade resolved by `collapse_operator_kind`.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum OperatorKind {
77 /// An interactive, single-Operator-driven session (spawn hooks and
78 /// full-spawn delegation are enabled).
79 MainAi,
80 /// A plain automated worker; middleware passes through into a normal
81 /// spawn (the default).
82 #[default]
83 Automate,
84 /// A mixed mode combining automated and MainAi-driven behavior (same
85 /// gating as `MainAi` for middleware purposes).
86 Composite,
87}
88
89impl From<mlua_swarm_schema::OperatorKind> for OperatorKind {
90 fn from(k: mlua_swarm_schema::OperatorKind) -> Self {
91 match k {
92 mlua_swarm_schema::OperatorKind::MainAi => OperatorKind::MainAi,
93 mlua_swarm_schema::OperatorKind::Automate => OperatorKind::Automate,
94 mlua_swarm_schema::OperatorKind::Composite => OperatorKind::Composite,
95 }
96 }
97}
98
99/// The single canonical implementation of the 4-tier `OperatorKind` cascade
100/// (schema doc: `mlua_swarm_schema::Blueprint::default_operator_kind`).
101///
102/// Each tier is optional; the first `Some` wins, top to bottom. All four
103/// absent falls back to `OperatorKind::default()` (Automate).
104///
105/// | tier | meaning |
106/// |---|---|
107/// | `runtime_agent` | per-agent override supplied at task-launch time (narrowest, most direct) |
108/// | `runtime_global` | the launch-time `operator_kind` request (session-wide) |
109/// | `bp_agent` | `OperatorDef.kind`, resolved per-agent via `AgentDef.spec.operator_ref` |
110/// | `bp_global` | `Blueprint.default_operator_kind` |
111///
112/// Consumed by `Engine::resolve_operator_info` (`crate::core::engine`), which
113/// supplies `runtime_agent` / `bp_agent` from per-agent `HashMap` lookups on
114/// `OperatorSession`, and `runtime_global` / `bp_global` from session-level
115/// fields — `runtime_global` is `OperatorSession.operator_kind` verbatim
116/// (an `Option<OperatorKind>`; `Some(_)` is always an explicit request,
117/// including `Some(Automate)`, and `None` means unspecified).
118pub fn collapse_operator_kind(
119 runtime_agent: Option<OperatorKind>,
120 runtime_global: Option<OperatorKind>,
121 bp_agent: Option<OperatorKind>,
122 bp_global: Option<OperatorKind>,
123) -> OperatorKind {
124 runtime_agent
125 .or(runtime_global)
126 .or(bp_agent)
127 .or(bp_global)
128 .unwrap_or_default()
129}
130
131#[cfg(test)]
132mod collapse_operator_kind_tests {
133 use super::*;
134
135 // (i) All tiers None → Default Fallback (Automate).
136 #[test]
137 fn all_none_falls_back_to_automate() {
138 assert_eq!(
139 collapse_operator_kind(None, None, None, None),
140 OperatorKind::Automate
141 );
142 }
143
144 // (ii) BP Global alone → BP Global value.
145 #[test]
146 fn bp_global_only_wins() {
147 assert_eq!(
148 collapse_operator_kind(None, None, None, Some(OperatorKind::MainAi)),
149 OperatorKind::MainAi
150 );
151 }
152
153 // (iii) BP Agent alone → BP Agent value.
154 #[test]
155 fn bp_agent_only_wins() {
156 assert_eq!(
157 collapse_operator_kind(None, None, Some(OperatorKind::MainAi), None),
158 OperatorKind::MainAi
159 );
160 }
161
162 // (iv) Runtime Global alone → Runtime Global value.
163 #[test]
164 fn runtime_global_only_wins() {
165 assert_eq!(
166 collapse_operator_kind(None, Some(OperatorKind::MainAi), None, None),
167 OperatorKind::MainAi
168 );
169 }
170
171 // (v) Runtime Agent alone → Runtime Agent value.
172 #[test]
173 fn runtime_agent_only_wins() {
174 assert_eq!(
175 collapse_operator_kind(Some(OperatorKind::MainAi), None, None, None),
176 OperatorKind::MainAi
177 );
178 }
179
180 // (vi) All tiers set → Runtime Agent value (narrow-wins check).
181 #[test]
182 fn all_tiers_set_runtime_agent_wins() {
183 assert_eq!(
184 collapse_operator_kind(
185 Some(OperatorKind::MainAi),
186 Some(OperatorKind::Composite),
187 Some(OperatorKind::Automate),
188 Some(OperatorKind::Composite),
189 ),
190 OperatorKind::MainAi
191 );
192 }
193
194 // (vii) BP Agent + Runtime Global together → Runtime Global (later-wins check).
195 #[test]
196 fn runtime_global_beats_bp_agent() {
197 assert_eq!(
198 collapse_operator_kind(
199 None,
200 Some(OperatorKind::Composite),
201 Some(OperatorKind::MainAi),
202 None,
203 ),
204 OperatorKind::Composite
205 );
206 }
207
208 // null merge: Runtime Agent-level unset for this agent but BP Agent set,
209 // BP Global also set → BP Agent (narrower) wins over BP Global.
210 #[test]
211 fn bp_agent_beats_bp_global_when_runtime_tiers_absent() {
212 assert_eq!(
213 collapse_operator_kind(
214 None,
215 None,
216 Some(OperatorKind::MainAi),
217 Some(OperatorKind::Composite),
218 ),
219 OperatorKind::MainAi
220 );
221 }
222
223 #[test]
224 fn schema_operator_kind_converts_into_ctx_operator_kind() {
225 assert_eq!(
226 OperatorKind::from(mlua_swarm_schema::OperatorKind::MainAi),
227 OperatorKind::MainAi
228 );
229 assert_eq!(
230 OperatorKind::from(mlua_swarm_schema::OperatorKind::Automate),
231 OperatorKind::Automate
232 );
233 assert_eq!(
234 OperatorKind::from(mlua_swarm_schema::OperatorKind::Composite),
235 OperatorKind::Composite
236 );
237 }
238}
239
240/// The bundle of Operator faces the engine injects into `Ctx` at dispatch.
241///
242/// # The three `Arc<dyn ...>` fields — the three Operator faces
243///
244/// Conceptually the Operator is one role, but inside the engine it fans out
245/// into three interception axes that fire independently. The canonical use
246/// is one external Operator (say, a WebSocket client) that implements all
247/// three traits and answers every axis from a single session (see
248/// a WebSocket-backed operator session in the server crate).
249///
250/// | field | trait | firing layer | purpose |
251/// |---|---|---|---|
252/// | `senior_bridge` | [`SeniorBridge`] | `SeniorEscalationMiddleware` | When a worker returns `ok = false`, query a judgment source and upgrade the outcome to Pass. |
253/// | `spawn_hook` | [`SpawnHook`] | `MainAIMiddleware` | Pre- and post-spawn observation and approve/reject gating (`kind = MainAi` / `Composite` only). |
254/// | `operator` | [`crate::operator::Operator`] | `OperatorDelegateMiddleware` | Delegate the entire spawn to an external Operator (bypass `inner.spawn` and call `execute`; `kind = MainAi` / `Composite` only). |
255///
256/// # The role of `kind`
257///
258/// Middleware uses `OperatorKind` (`Automate` / `MainAi` / `Composite`) as a
259/// gating signal: `MainAi` / `Composite` enable `spawn_hook` and `operator`;
260/// `Automate` lets middleware pass through into a normal spawn.
261/// `senior_bridge` is kind-agnostic and fires whenever `ok = false`.
262///
263/// # Default
264///
265/// `OperatorKind::Automate` with all three `Arc<dyn ...>` fields set to
266/// `None`. Middleware passes through; execution stays inline as usual.
267///
268/// # Persistence boundary
269///
270/// `OperatorInfo` is transient inside `Ctx` (`#[serde(skip)]`). The
271/// persisted `OperatorSession` only holds IDs (`bridge_id` / `hook_id` /
272/// `operator_backend_id`). At dispatch time the engine resolves each `Arc`
273/// by looking those IDs up in its `senior_bridges` / `spawn_hooks` /
274/// `operators` `HashMap`s via `resolve_operator_info(session) -> OperatorInfo`.
275#[derive(Clone)]
276pub struct OperatorInfo {
277 /// Gating signal consumed by middleware; see the "role of `kind`"
278 /// section above.
279 pub kind: OperatorKind,
280 /// Identifier of the attached Operator/session (`OperatorSession.operator_id`).
281 pub id: String,
282 /// See the `senior_bridge` row in the table above.
283 pub senior_bridge: Option<Arc<dyn SeniorBridge>>,
284 /// See the `spawn_hook` row in the table above.
285 pub spawn_hook: Option<Arc<dyn SpawnHook>>,
286 /// See the `operator` row in the table above.
287 pub operator: Option<Arc<dyn crate::operator::Operator>>,
288}
289
290impl std::fmt::Debug for OperatorInfo {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("OperatorInfo")
293 .field("kind", &self.kind)
294 .field("id", &self.id)
295 .field("senior_bridge", &self.senior_bridge.is_some())
296 .field("spawn_hook", &self.spawn_hook.is_some())
297 .field("operator", &self.operator.is_some())
298 .finish()
299 }
300}
301
302impl Default for OperatorInfo {
303 fn default() -> Self {
304 Self {
305 kind: OperatorKind::Automate,
306 id: "default-automate".into(),
307 senior_bridge: None,
308 spawn_hook: None,
309 operator: None,
310 }
311 }
312}
313
314/// Escalation channel fired by `SeniorEscalationMiddleware` whenever a
315/// worker returns `ok = false`: a chance for a "senior" judgment source to
316/// review and potentially upgrade the outcome to Pass.
317#[async_trait]
318pub trait SeniorBridge: Send + Sync {
319 /// Ask the Senior a question and wait for the answer (`Value`). The
320 /// implementation is free — a CLI prompt, an MCP modal, another
321 /// process, whatever.
322 async fn ask(&self, task_id: &TaskId, question: Value) -> Result<Value, String>;
323}
324
325/// Pre-/post-spawn observation and gating hook fired by
326/// `MainAIMiddleware` (only when `OperatorKind` is `MainAi` / `Composite`).
327#[async_trait]
328pub trait SpawnHook: Send + Sync {
329 /// Hook fired **before** the spawn. Returning `Err` aborts the spawn.
330 async fn before(&self, ctx: &Ctx) -> Result<(), String>;
331 /// Hook fired **after** the spawn (once the worker has finished).
332 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String>;
333}