Skip to main content

mlua_swarm_server/operator_ws/
protocol.rs

1//! Wire format (= S↔C JSON message schema) for `WS /v1/operators/:sid/ws`.
2//!
3//! `ServerMsg` = 4 messages the server pushes to the client (Ask / HookBefore /
4//! HookAfter / Spawn).
5//! `ClientMsg` = 4 messages the client replies with (Answer / HookAck / SpawnAck / SpawnHalt).
6//!
7//! ## Layer 2 audit (issue #7): halt vs error separation across verbs
8//!
9//! `spawn_halt` was added to disambiguate a controlled halt from a real
10//! worker error on the `spawn_ack` axis. The other client verbs were
11//! audited for the same shape:
12//!
13//! - **`answer`** (SeniorBridge.ask reply): carries `value` only — no
14//!   ok / failure axis exists. A "halt during a question" would already
15//!   be expressible by replying with a value that the middleware treats
16//!   as an abort signal (e.g. `null` or a domain-specific sentinel).
17//!   No sibling `answer_halt` is added.
18//! - **`hook_ack`** (SpawnHook.before OK/NG): `ok = false` here is a
19//!   genuine gate rejection — that is the entire purpose of the hook,
20//!   not a mix-signal. There is no confusion to fix; no `hook_halt` is
21//!   added.
22//! - **`spawn_ack`**: the subject of layer 1. `spawn_halt` handles it.
23//! For the parent module's message-flow figure, see the doc of `mod.rs`.
24//!
25//! `PendingReply` is the intermediate representation delivered over the internal
26//! `oneshot` reply channel, used to resolve a `ClientMsg` (arriving from a client)
27//! against the session's pending `HashMap` keyed by `req_id`.
28//! See `session::WSOperatorSession::resolve_pending` for details.
29
30use mlua_swarm::{StepId, WorkerBinding};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34// parent_req_id schema field carry: the engine middleware does not fire the
35// true nested-ask case (another ask running mid-ask), so this stays None.
36// The field is kept for schema compatibility; when a middleware extension
37// starts firing the true nested case, it can be reintroduced via task_local
38// or similar.
39pub(super) fn current_parent_req_id() -> Option<String> {
40    None
41}
42
43pub(super) fn default_ok_true() -> bool {
44    true
45}
46
47/// Server → client push messages on `WS /v1/operators/:sid/ws`. Each variant
48/// pairs with a `ClientMsg` reply carrying the same `req_id` (except
49/// `HookAfter`, which is fire-and-forget).
50#[derive(Debug, Serialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum ServerMsg {
53    /// `SeniorBridge.ask` request.
54    Ask {
55        /// Correlation key the client must echo back in `ClientMsg::Answer`.
56        req_id: String,
57        /// Reserved for nested-ask correlation; currently always `None`
58        /// (see [`current_parent_req_id`]).
59        #[serde(skip_serializing_if = "Option::is_none")]
60        parent_req_id: Option<String>,
61        /// Task the question originates from. Typed [`StepId`] since issue
62        /// #14 — serde keeps the wire shape a plain string.
63        task_id: StepId,
64        /// Free-form question payload produced by the engine middleware.
65        question: Value,
66    },
67    /// `SpawnHook.before` request (= the client returns OK / NG via ack).
68    HookBefore {
69        /// Correlation key the client must echo back in `ClientMsg::HookAck`.
70        req_id: String,
71        /// Reserved for nested-ask correlation; currently always `None`.
72        #[serde(skip_serializing_if = "Option::is_none")]
73        parent_req_id: Option<String>,
74        /// Task whose spawn is being gated.
75        task_id: StepId,
76        /// Agent ref about to be spawned.
77        agent: String,
78        /// 1-based dispatch attempt counter for this agent step.
79        attempt: u32,
80    },
81    /// `SpawnHook.after` notification (= no client ack, fire-and-forget).
82    HookAfter {
83        /// Correlation key (informational only — no reply is expected).
84        req_id: String,
85        /// Reserved for nested-ask correlation; currently always `None`.
86        #[serde(skip_serializing_if = "Option::is_none")]
87        parent_req_id: Option<String>,
88        /// Task the spawn belonged to.
89        task_id: StepId,
90        /// Agent ref that was spawned.
91        agent: String,
92        /// 1-based dispatch attempt counter for this agent step.
93        attempt: u32,
94        /// Worker result payload observed after the spawn completed.
95        result: Value,
96    },
97    /// `Operator.execute` request (= delegates the whole spawn to an external
98    /// Operator, via `OperatorDelegateMiddleware`). The client replies with the
99    /// `WorkerResult`-equivalent (= value + ok) in `spawn_ack`.
100    ///
101    /// **Thin control channel** (the Spawn thin-control axis): the server sends only
102    /// the `capability_token`. `system_prompt` / `prompt` are NOT carried in the
103    /// WS payload. The MainAI (WS Client) forwards the token to the SubAgent,
104    /// and the SubAgent hits `/v1/worker/prompt` + `/v1/worker/result` itself
105    /// with `Authorization: Bearer <capability_token>` — fetching prompt /
106    /// system and posting the result (= heavy payloads go over HTTP; WS stays
107    /// purely thin control).
108    ///
109    /// `capability_token` is `CapToken::encode()` form (= URL-safe base64 of
110    /// serde_json): a session token with `Role::Worker` + `["*"]` scopes + 600s
111    /// TTL. The HMAC sig is verified server-side by `verify_token_for_task` —
112    /// a self-contained capability token (= no server lookup required).
113    ///
114    /// `directive` (= immediate instruction for the MainAI; fix for observation #7):
115    /// Under thin-push discipline, if the payload were only routing fields, the
116    /// MainAI (a large LLM) would fire the drift "I have a token → I should
117    /// fetch it myself" / "I got the prompt → I should embed it literally into
118    /// the SubAgent" 100% of the time (= bias accumulation across 50–100 parallel
119    /// agents dulls decisions). To structurally remove this drift, a literal
120    /// instruction text — "launch a SubAgent, hand it the token + endpoint, and
121    /// let the SubAgent do the fetch / execution / post" — is explicitly embedded
122    /// into the payload (= implicit convention → literal statement).
123    ///
124    /// This field carries **natural-language text intended for the MainAI to read**
125    /// (= not a JSON schema target for parsing). See
126    /// `operator_ws::session::default_spawn_directive()` for the server-side
127    /// default text.
128    Spawn {
129        /// Correlation key the client must echo back in `ClientMsg::SpawnAck`.
130        req_id: String,
131        /// Reserved for nested-ask correlation; currently always `None`.
132        #[serde(skip_serializing_if = "Option::is_none")]
133        parent_req_id: Option<String>,
134        /// Task the delegated spawn belongs to.
135        task_id: StepId,
136        /// Agent ref the Operator is asked to execute.
137        agent: String,
138        /// 1-based dispatch attempt counter for this agent step.
139        attempt: u32,
140        /// `CapToken::encode()` form Bearer credential for the worker HTTP
141        /// endpoints (see the variant doc above for the thin-control contract).
142        capability_token: String,
143        /// Short handle (= `wh-XXXXXXXX`, 12 chars). An alternate Bearer path
144        /// paired with `capability_token`. When `/v1/worker/submit` receives a
145        /// handle in Bearer, the server resolves nonce → `task_id` via the
146        /// `worker_handles` map (the short-handle switchover — removes
147        /// base64 copy-paste accidents). SubAgents (mse-worker) should use
148        /// **this field** instead of `capability_token` as the recommended path.
149        #[serde(skip_serializing_if = "Option::is_none")]
150        worker_handle: Option<String>,
151        /// Worker binding resolved from the Blueprint at compile time. `None`
152        /// never reaches the wire on the WS thin path (compile-time gate,
153        /// see `Operator::requires_worker_binding`), but the field stays
154        /// optional for forward compatibility.
155        #[serde(skip_serializing_if = "Option::is_none")]
156        worker: Option<WorkerBinding>,
157        /// Literal natural-language instruction for the MainAI (see the
158        /// variant doc above for why this is embedded in the payload).
159        directive: String,
160    },
161}
162
163/// Client → server reply messages on `WS /v1/operators/:sid/ws`. Each variant
164/// resolves the pending oneshot registered under its `req_id`
165/// (see [`super::session::WSOperatorSession::resolve_pending`]).
166#[derive(Debug, Deserialize)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum ClientMsg {
169    /// Reply to `ServerMsg::Ask` (`SeniorBridge.ask` result).
170    Answer {
171        /// Correlation key copied from the originating `ServerMsg::Ask`.
172        req_id: String,
173        /// Answer payload returned to the engine middleware.
174        value: Value,
175    },
176    /// Ack for `SpawnHook.before`. `ok=false` rejects the spawn
177    /// (= `MainAIMiddleware` converts it into `SpawnError::RejectedByMiddleware`).
178    /// `reason` propagates as `Err(reason)`.
179    HookAck {
180        /// Correlation key copied from the originating `ServerMsg::HookBefore`.
181        req_id: String,
182        /// `true` allows the spawn; `false` rejects it.
183        ok: bool,
184        /// Optional rejection reason surfaced to the engine when `ok=false`.
185        #[serde(default)]
186        reason: Option<String>,
187    },
188    /// Ack for `Operator.execute` (Spawn). `value = WorkerResult.value`,
189    /// `ok = WorkerResult.ok`. When `error` is `Some`, the `Operator` returns
190    /// it as `WorkerError`.
191    ///
192    /// After the thin-path switch (= the thin-control axis): if the MainAI returns this ack
193    /// **after** the SubAgent has hit HTTP `/v1/worker/result`, the server-side
194    /// dispatch path can complete with both the `Final` in `output_tail` and
195    /// this ack's `value` aligned. Sending an empty JSON `{}` for `value` makes
196    /// the `task.last_result` written by the HTTP path (= `post_result`)
197    /// canonical (= the ack-side `value` is duplicate / informational).
198    SpawnAck {
199        /// Correlation key copied from the originating `ServerMsg::Spawn`.
200        req_id: String,
201        /// `WorkerResult.value` equivalent; empty `{}` defers to the HTTP-path
202        /// result (see the variant doc above).
203        #[serde(default)]
204        value: Value,
205        /// `WorkerResult.ok` equivalent; defaults to `true` when omitted.
206        #[serde(default = "default_ok_true")]
207        ok: bool,
208        /// When `Some`, the Operator surfaces it as a `WorkerError`.
209        #[serde(default)]
210        error: Option<String>,
211    },
212    /// Controlled halt for the current spawn (issue #7). Distinct from
213    /// `SpawnAck { ok: false, error: Some(_) }`, which is the fail-loud
214    /// path for real worker errors. `spawn_halt` signals the operator's
215    /// intent to end the current spawn as a normal termination:
216    ///
217    /// - The step return value is `WorkerResult { value: <halt marker>,
218    ///   ok: true }` — no `WorkerError` is raised, so log level stays
219    ///   `info` and downstream retry logic doesn't fire.
220    /// - The optional `value` payload is merged into the halt marker
221    ///   under `value`, so partial results reach `final_ctx` verbatim.
222    /// - `reason` is a human-readable log line.
223    ///
224    /// The halt marker written to ctx has the shape:
225    /// ```json
226    /// { "halted": true, "reason": "<reason or null>", "value": <payload or {}> }
227    /// ```
228    /// Blueprint flows that need to short-circuit downstream steps on
229    /// halt can `branch` on `$.<step_out>.halted`.
230    ///
231    /// **Scope note**: this halts one spawn, not the whole swarm. For
232    /// swarm-wide cancellation see `swarm_cancel`.
233    SpawnHalt {
234        /// Correlation key copied from the originating `ServerMsg::Spawn`.
235        req_id: String,
236        /// Optional partial ctx value to carry into `WorkerResult.value`.
237        /// Merged under the `value` key of the halt marker; defaults to `{}`.
238        #[serde(default)]
239        value: Value,
240        /// Optional human-readable halt reason (for logs). Included in
241        /// the halt marker under `reason`.
242        #[serde(default)]
243        reason: Option<String>,
244    },
245}
246
247/// Intermediate representation for the session's `req_id` ↔ oneshot reply
248/// channel. The resolved form of `ClientMsg` looked up on the session side by
249/// `req_id` (= runtime-only, not wire format).
250pub(super) enum PendingReply {
251    /// Answer (return `Value` of `SeniorBridge.ask`).
252    Answer(Value),
253    /// `hook_ack` (OK / NG for `before`).
254    HookAck { ok: bool, reason: Option<String> },
255    /// `spawn_ack` (return of `Operator.execute` = `WorkerResult`-equivalent).
256    SpawnAck {
257        value: Value,
258        ok: bool,
259        error: Option<String>,
260    },
261    /// `spawn_halt` — controlled halt for the current spawn (issue #7).
262    /// See the `ClientMsg::SpawnHalt` doc for semantics.
263    SpawnHalt {
264        value: Value,
265        reason: Option<String>,
266    },
267}