mlua_swarm/operator.rs
1//! Operator abstraction.
2//!
3//! ## Roles
4//!
5//! - **Spawners** (`SpawnerAdapter`) do not know about `Operator` `kind`s.
6//! Ordinary dispatches are handled by `ProcessSpawner` /
7//! `InProcSpawner` / etc.
8//! - `OperatorSpawner` is the `SpawnerAdapter` that routes dispatches
9//! through an operator. It holds an `Arc<dyn Operator>` and does one
10//! thing: hand every spawn request to that operator's `execute`. It
11//! still does not know the operator's `kind` (`MainAi` / `Human` /
12//! `Automate` / `Composite`).
13//! - The `Operator` trait itself returns a `WorkerResult`, as a
14//! synchronous backend. Implementations are free per kind — a `MainAi`
15//! operator might round-trip through Claude via an HTTP callback, a
16//! `Human` operator might prompt on a CLI, an `Automate` operator
17//! might delegate to a different spawner, and so on.
18//!
19//! Which dispatches go through the `OperatorSpawner` is decided at the
20//! flow.ir layer (designer + hints + Swarm compiler). The algocline
21//! strategy side never says "hand this to the operator" — a firm
22//! separation of concerns.
23
24pub mod render;
25
26pub use render::{render_system, slots_from_prompt, RenderError};
27
28use crate::core::ctx::Ctx;
29use crate::core::engine::Engine;
30use crate::types::{CapToken, StepId, WorkerId};
31use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
32use crate::worker::output::{ContentRef, OutputEvent};
33use crate::worker::{Worker, WorkerJoinHandler};
34use async_trait::async_trait;
35use serde_json::Value;
36use std::sync::Arc;
37use tokio::sync::oneshot;
38use tokio_util::sync::CancellationToken;
39
40/// Worker binding baked from `AgentDef.profile` at compile time — which
41/// worker variant the operator backend must run, plus the tool surface
42/// the Blueprint declared for this agent.
43///
44/// `variant` is mse domain vocabulary; backend-specific terms (e.g. the
45/// Claude Code Agent tool's `subagent_type` parameter) belong to the
46/// rendering boundary (`operator_ws::session` directive render), not here.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct WorkerBinding {
49 /// Worker variant name (for the Claude Code backend this maps onto
50 /// the Agent tool `subagent_type` at directive-render time).
51 #[serde(alias = "subagent_type")]
52 pub variant: String,
53 /// Tool list declared in `AgentDef.profile.tools` (informational
54 /// for the MainAI / observability; the SubAgent's own frontmatter
55 /// is what actually grants tools).
56 pub tools: Vec<String>,
57 /// Digest of the immutable declaration-only `BoundAgent` snapshot this
58 /// binding was resolved from (`sha256:<hex>`). Carried into the spawn
59 /// frame so a non-strict Operator can correlate the request and self-check
60 /// its own environment against it. Like `tools`, this is informational —
61 /// a self-check input for the Operator, not a Server-enforced gate. `None`
62 /// on construction sites that have no snapshot (compile-time / test paths).
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub request_digest: Option<crate::blueprint::BindingDigest>,
65 /// Model name or tier declared in `AgentDef.profile.model`, forwarded so
66 /// the Operator can compare the requested model against what its
67 /// environment actually runs. Informational (self-check input), not an
68 /// enforcement field. `None` when the profile declares no model.
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub requested_model: Option<String>,
71}
72
73/// The `Operator` trait: takes a spawn request and returns a
74/// `WorkerResult`. The backend for `OperatorSpawner`. Implementations
75/// are free to differ per kind; the spawner just calls `execute` and
76/// stays out of the internals.
77///
78/// Arguments — a two-slot payload plus `worker_token` (the thin path
79/// was added later) plus `worker` (the Blueprint-baked binding, added
80/// later still):
81///
82/// - `system`: the agent persona — the rendered value of
83/// `AgentDef.profile.system_prompt` after template expansion. `None`
84/// means no profile. Expected to map straight onto the LLM API's
85/// system message; direct-LLM operators consume this.
86/// - `prompt`: task-specific intent — `TaskSpec.initial_directive`,
87/// pulled server-side via `engine.fetch_prompt`. Expected to map
88/// straight onto the LLM API's user message.
89/// - `worker`: the compile-time-baked [`WorkerBinding`] (subagent type +
90/// declared tools) resolved from `AgentDef.profile.worker_binding`.
91/// `None` for agents whose profile has no `worker_binding` set.
92/// Backends that require one (see [`Operator::requires_worker_binding`])
93/// must fail loud rather than silently degrade when this is `None`.
94/// - `worker_token`: a capability token (`Role::Worker`,
95/// `scopes = ["*"]`, TTL from
96/// [`EngineCfg::worker_token_ttl_secs`](crate::EngineCfg) — default
97/// 1800s). Thin-path operators (a `a WebSocket-backed operator session`,
98/// for instance) `encode()` this token and hand it to the MainAI
99/// WebSocket client, so the SubAgent can hit `/v1/worker/prompt` +
100/// `/v1/worker/result` with `Authorization: Bearer <encoded>`.
101/// Direct-LLM operators may ignore it.
102///
103/// The trait passes both slots so the same signature works for the
104/// thin path and the direct path; the implementation picks which one
105/// it takes (consume the server-rendered `system` directly, or forward
106/// the token and let the client fetch).
107#[async_trait]
108pub trait Operator: Send + Sync {
109 /// Executes one spawn request against this operator's backend and
110 /// returns the resulting `WorkerResult` (or a `WorkerError` if the
111 /// backend failed). See the trait doc above for the meaning of each
112 /// argument.
113 async fn execute(
114 &self,
115 ctx: &Ctx,
116 system: Option<String>,
117 prompt: Value,
118 worker: Option<WorkerBinding>,
119 worker_token: CapToken,
120 ) -> Result<WorkerResult, WorkerError>;
121
122 /// Whether this operator backend requires a non-`None` `worker`
123 /// binding to execute at all. `false` by default (direct-LLM
124 /// operators consume `system` / `prompt` directly and have no
125 /// SubAgent to dispatch). WS thin-path operators override this to
126 /// `true` — the compiler uses it to fail loud at `compile()` time
127 /// when `AgentDef.profile.worker_binding` is absent, rather than
128 /// silently degrading at dispatch time.
129 fn requires_worker_binding(&self) -> bool {
130 false
131 }
132}
133
134/// Resolves the `Arc<dyn Operator>` a Blueprint-declared Operator seat
135/// dispatches through.
136///
137/// # Why a hook instead of a lookup
138///
139/// `AgentDef.spec.operator_ref` names a **seat** (one of
140/// `Blueprint.operators[]`), not a backend. Historically
141/// [`OperatorSpawnerFactory`](crate::OperatorSpawnerFactory) answered it by
142/// looking the name up in its own `id → Arc<dyn Operator>` map, which baked
143/// whichever session held that name at compile time into
144/// `routes[agent_name]` for the whole Run — so re-assigning the seat later
145/// could not change where a dispatch went (model §4.3 **A10**: *the
146/// destination is not baked in*).
147///
148/// A host that records seat holders per Run installs a resolver instead. It
149/// is handed the seat name and returns the indirection that performs the
150/// per-dispatch holder lookup (`mlua-swarm-server`'s `AssigneeRouter`), so
151/// what gets baked is **which seat**, never **who holds it**.
152///
153/// The hook lives here rather than in the compiler because the resolving
154/// type needs a `RunStore` and the live session registry, both of which are
155/// the host's; the core only needs to know that something can answer
156/// "operator for seat *X*".
157pub trait OperatorSlotResolver: Send + Sync {
158 /// The backend for `slot`, or `None` when this resolver cannot serve
159 /// that seat — which fails the compile loudly (there is deliberately no
160 /// fallback to the factory's own registry, since falling back is how a
161 /// dispatch ends up somewhere the caller never named).
162 fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>>;
163}
164
165/// A `SpawnerAdapter` implementation that hands the dispatch off to an
166/// `Arc<dyn Operator>`.
167///
168/// `OperatorSpawner` itself does not inspect the operator's `kind` —
169/// `MainAi` / `Human` / `Automate` / `Composite` all go through the same
170/// path, and the operator implementation absorbs the differences.
171///
172/// # Position — the AgentSpec-axis Operator path
173///
174/// Use this type on the path that **bakes a separate Operator backend
175/// into every `AgentDef`**. For an `AgentKind::Operator` `AgentDef`, the
176/// `OperatorSpawnerFactory` produces one with
177/// `OperatorSpawner::new(op, system_prompt, worker_binding)` and places it
178/// in `routes[agent_name]`. Agents flowing in through the `agent.md`
179/// loader default to `kind = Operator`, so they land here.
180///
181/// The paired **Blueprint-global (session) axis** is
182/// `crate::middleware::OperatorDelegateMiddleware` — a single operator
183/// backend registered on the session and applied uniformly across every
184/// agent. When both are effective, the delegate middleware sits at the
185/// outer end of the stack and bypasses `inner.spawn`; this type is inert
186/// and no double fire can occur. See the `OperatorSpawnerFactory` doc
187/// for the exclusivity narrative.
188pub struct OperatorSpawner {
189 operator: Arc<dyn Operator>,
190 /// The compile-time-baked `AgentDef.profile.system_prompt` — the
191 /// agent's persona. If `Some`, it takes priority at spawn time; if
192 /// `None`, we fall back to `fetch_prompt` (`initial_directive`).
193 system_prompt: Option<String>,
194 /// The compile-time-baked worker binding — resolved from
195 /// `AgentDef.profile.worker_binding` by `OperatorSpawnerFactory`.
196 /// Passed straight through to `Operator::execute` on every spawn.
197 worker_binding: Option<WorkerBinding>,
198}
199
200impl OperatorSpawner {
201 /// Binds an operator backend plus an optional compile-time
202 /// `system_prompt` template (rendered per-spawn via `render_system`)
203 /// and an optional compile-time-baked `worker_binding`.
204 pub fn new(
205 operator: Arc<dyn Operator>,
206 system_prompt: Option<String>,
207 worker_binding: Option<WorkerBinding>,
208 ) -> Self {
209 Self {
210 operator,
211 system_prompt,
212 worker_binding,
213 }
214 }
215}
216
217#[async_trait]
218impl SpawnerAdapter for OperatorSpawner {
219 async fn spawn(
220 &self,
221 engine: &Engine,
222 ctx: &Ctx,
223 task_id: StepId,
224 attempt: u32,
225 token: CapToken,
226 ) -> Result<Box<dyn Worker>, SpawnError> {
227 // By convention the spawner pulls `prompt`
228 // through `fetch_prompt`. The `system_prompt` (from
229 // `AgentDef.profile`) travels on the other slot — sibling to the
230 // AgentBlock path's `BlockConfig.context` / `.prompt` split.
231 let prompt = engine
232 .fetch_prompt(&token, &task_id)
233 .await
234 .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
235
236 // Render the `system_prompt` template.
237 // Expand the prompt into a slot map and hand the template to
238 // minijinja. The syntax used inside the agent.md body is
239 // Jinja2-compatible (`{{ directive }}` / `{% if intent %}` /
240 // `{{ x | upper }}`), with strict undefined variables and
241 // auto-escape disabled.
242 let system = match self.system_prompt.as_deref() {
243 Some(tmpl) => {
244 let slots = render::slots_from_prompt(&prompt);
245 let rendered = render::render_system(tmpl, &slots)
246 .map_err(|e| SpawnError::Internal(format!("render system_prompt: {e}")))?;
247 Some(rendered)
248 }
249 None => None,
250 };
251
252 // Bake the rendered `system`
253 // into engine state so the SubAgent can fetch it alongside
254 // `prompt` on the `HTTP /v1/worker/prompt` path. Failures are
255 // fail-loud via `SpawnError::Internal` — no silent fallback.
256 engine
257 .bake_worker_system_prompt(&task_id, attempt, system.clone())
258 .await
259 .map_err(|e| SpawnError::Internal(format!("bake system_prompt: {e}")))?;
260
261 let op = self.operator.clone();
262 let engine_clone = engine.clone();
263 let token_clone = token.clone();
264 let token_for_op = token.clone();
265 let task_id_clone = task_id.clone();
266 let ctx_clone = ctx.clone();
267 let worker_binding = self.worker_binding.clone();
268 let (tx, rx) = oneshot::channel();
269 let cancel = CancellationToken::new();
270 let cancel_inner = cancel.clone();
271 let worker_id = WorkerId::new();
272 // issue #11: surface the minted WorkerId in the trace log.
273 tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (operator spawner)");
274
275 tokio::spawn(async move {
276 let result: Result<WorkerResult, WorkerError> = tokio::select! {
277 r = op.execute(&ctx_clone, system, prompt, worker_binding, token_for_op) => r,
278 _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
279 };
280 // Per-step run stats: the WS operator ack may attach
281 // harness-reported SubAgent usage — forward it to the
282 // engine so the dispatcher's outcome fold lands it on the
283 // terminal StepEntry. Even without stats attached,
284 // `ensure_worker_kind` guarantees the `worker_kind:
285 // "operator"` label always rides (mirrors the sibling
286 // `OperatorDelegateMiddleware` fold site).
287 let result = result.map(|wr| wr.ensure_worker_kind("operator"));
288 if let Ok(wr) = &result {
289 if let Some(stats) = wr.stats.clone() {
290 engine_clone
291 .record_worker_stats(&task_id_clone, attempt, stats)
292 .await;
293 }
294 }
295 // Emit `WorkerResult` → `OutputEvent::Final` in
296 // parallel. If the SubAgent already
297 // pushed a `Final` via HTTP (`/v1/worker/result` or
298 // `/v1/worker/submit`), skip. The POSTed value is canonical
299 // — protocol.rs L107-110 design intent. Only operator
300 // implementations that do not POST (tests, inline
301 // operators) need this fallback emit.
302 if let Ok(wr) = &result {
303 let tail = engine_clone.output_tail(&task_id_clone, attempt).await;
304 let has_final = tail
305 .iter()
306 .any(|ev| matches!(ev, OutputEvent::Final { .. }));
307 if !has_final {
308 let ev = OutputEvent::Final {
309 content: ContentRef::Inline {
310 value: wr.value.clone(),
311 },
312 ok: wr.ok,
313 };
314 // GH #51: `submit_output` now embeds the
315 // completion-time verdict-contract check (see
316 // `Engine::verdict_contract_completion_check`'s doc)
317 // — this fallback emit is gated by it exactly like
318 // the HTTP routes are, with zero new WS protocol
319 // surface. On rejection the `Final` is simply never
320 // written: `output_tail` stays without one, and the
321 // downstream `dispatch_attempt_with` Final-pull
322 // naturally treats the attempt as incomplete — no new
323 // reject-back-to-client message is synthesized (the
324 // deliberate "Zero flow-ir changes" design choice, not
325 // a gap to fill).
326 if let Err(e) = engine_clone
327 .submit_output(&token_clone, &task_id_clone, attempt, ev)
328 .await
329 {
330 tracing::warn!(
331 step_id = %task_id_clone,
332 attempt,
333 error = %e,
334 "operator fallback Final rejected by verdict-contract completion gate"
335 );
336 }
337 }
338 }
339 let signal: Result<(), WorkerError> = result.map(|_| ());
340 let _ = tx.send(signal);
341 });
342
343 Ok(Box::new(OperatorWorker {
344 handler: WorkerJoinHandler {
345 worker_id,
346 cancel,
347 completion: rx,
348 },
349 }))
350 }
351}
352
353/// Concrete Worker type for the Operator kind — wraps the async
354/// `Operator::execute` call. This represents the handle for a task
355/// backed by an operator (SDK, WebSocket bridge, direct LLM call, etc.)
356/// and embeds a `WorkerJoinHandler` that carries the async signal.
357pub struct OperatorWorker {
358 /// The completion-signal handle for this operator call's spawned
359 /// task.
360 pub handler: WorkerJoinHandler,
361}
362
363#[async_trait]
364impl Worker for OperatorWorker {
365 fn id(&self) -> &WorkerId {
366 &self.handler.worker_id
367 }
368 fn cancel_token(&self) -> CancellationToken {
369 self.handler.cancel.clone()
370 }
371 async fn join(self: Box<Self>) -> Result<(), WorkerError> {
372 self.handler.await_completion().await
373 }
374}