mlua_swarm/worker/adapter.rs
1//! The second stage of the two-stage pipeline: `SpawnerAdapter`.
2//!
3//! From the engine's viewpoint there is only one trait,
4//! `SpawnerAdapter`; its `spawn` returns `Box<dyn Worker>` (see
5//! `crate::worker::Worker`). Worker shape is an implementation detail of
6//! each spawner; the engine only touches Workers through three
7//! operations — `id()` / `cancel_token()` / `join()`.
8//!
9//! The old `WorkerAdapter` trait and `InProcWorker` struct — which
10//! assumed a three-stage `Spawner.spawn → WorkerAdapter → invoke`
11//! pipeline — were removed on this turn. Nothing instantiated or
12//! dispatched them (dead code), and the multi-invocation path from
13//! was collapsed in the implementation anyway.
14//! The interface is now consolidated into the new `trait Worker` in
15//! `src/worker.rs`.
16
17use crate::core::agent_context::AgentContextView;
18use crate::core::ctx::Ctx;
19use crate::core::engine::Engine;
20use crate::types::{CapToken, StepId};
21use crate::worker::Worker;
22use async_trait::async_trait;
23use serde_json::Value;
24use std::collections::HashMap;
25use std::future::Future;
26use std::pin::Pin;
27use std::sync::Arc;
28use thiserror::Error;
29
30/// Errors that can occur while `SpawnerAdapter::spawn` is setting up a
31/// worker, before the worker itself starts running.
32#[derive(Debug, Error)]
33pub enum SpawnError {
34 /// No `WorkerFn` is registered for the requested agent name.
35 #[error("worker not registered: {0}")]
36 NotRegistered(String),
37 /// A middleware layer vetoed the spawn (e.g. capability check, rate
38 /// limit, policy gate).
39 #[error("spawn rejected by middleware: {0}")]
40 RejectedByMiddleware(String),
41 /// Any other setup failure (e.g. `fetch_prompt` failed).
42 #[error("internal: {0}")]
43 Internal(String),
44}
45
46/// Errors surfaced once a worker is running, via `Worker::join`.
47#[derive(Debug, Error)]
48pub enum WorkerError {
49 /// The worker fn itself returned an error.
50 #[error("worker fn returned error: {0}")]
51 Failed(String),
52 /// The worker was cancelled through its `CancellationToken`.
53 #[error("cancelled")]
54 Cancelled,
55}
56
57/// The value a `WorkerFn` hands back on success, folded into an
58/// `OutputEvent::Final` by the spawner.
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct WorkerResult {
61 /// The worker fn's output payload.
62 pub value: Value,
63 /// Whether the agent itself considers this a successful result
64 /// (distinct from `Result::Err` — a worker fn can return `Ok(..)`
65 /// with `ok: false` to signal an agent-level failure).
66 pub ok: bool,
67 /// Optional normalized per-attempt stats sidecar (token usage /
68 /// model / num_turns / adapter-specific raw data), produced by the
69 /// worker boundary that knows them (agent-block result captor,
70 /// subprocess stdout normalization, …). The spawner's fold site
71 /// forwards it to `Engine::record_worker_stats`; it never rides
72 /// into `OutputEvent::Final` (the BP-chain value stays stats-free).
73 /// `None` = no stats reported — every pre-stats worker fn is
74 /// unaffected (`#[serde(default)]` keeps wire compat).
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub stats: Option<crate::store::trace::WorkerStats>,
77}
78
79impl WorkerResult {
80 /// Ensure `stats.worker_kind` is set — the design invariant that
81 /// every dispatched step's terminal `StepEntry` carries at least
82 /// its worker kind label ("rust_fn" / "lua" / "agent_block" /
83 /// "subprocess" / "operator" / …), even when no LLM-shaped stats
84 /// (usage / model / num_turns) apply. Idempotent: if a boundary
85 /// already reported a `worker_kind` (agent_block / subprocess
86 /// stats sidecar, operator ack), that value wins; otherwise the
87 /// fold site's `kind` becomes the value. Called at every worker
88 /// fold site (`InProcSpawner` spawn task, subprocess spawn task,
89 /// `OperatorDelegateMiddleware`).
90 pub fn ensure_worker_kind(mut self, kind: &str) -> Self {
91 let stats = self
92 .stats
93 .get_or_insert_with(crate::store::trace::WorkerStats::default);
94 if stats.worker_kind.is_none() {
95 stats.worker_kind = Some(kind.to_string());
96 }
97 self
98 }
99}
100
101/// First stage of the two-stage pipeline: builds a `Box<dyn Worker>` for
102/// one attempt. Every concrete spawner (`InProcSpawner`, `ProcessSpawner`,
103/// the Operator spawner) implements this; the engine only ever holds a
104/// `Arc<dyn SpawnerAdapter>` and knows nothing about the Worker shape
105/// behind it.
106#[async_trait]
107pub trait SpawnerAdapter: Send + Sync {
108 /// Spawn one attempt as a worker. Returns `Box<dyn Worker>`.
109 ///
110 /// The `directive` argument was removed in design intent: prompts are
111 /// pulled on demand through
112 /// `engine.fetch_prompt(token, task_id, attempt)`. Spawners are free
113 /// to use whatever protocol they like internally — push, pull, or a
114 /// hybrid. `ProcessSpawner` runs `fetch_prompt` and pushes the
115 /// result into the child's stdin; `InProcSpawner` injects a prep
116 /// snapshot as `WorkerInvocation.prompt`; a child process could
117 /// even re-pull with the token itself.
118 async fn spawn(
119 &self,
120 engine: &Engine,
121 ctx: &Ctx,
122 task_id: StepId,
123 attempt: u32,
124 token: CapToken,
125 ) -> Result<Box<dyn Worker>, SpawnError>;
126}
127
128// ─── InProcSpawner ────────────────────────────────────────────────────────
129
130/// Invocation context handed to a Worker fn. Bundles `token` +
131/// `task_id` + `prompt` + `sink` + `context`.
132///
133/// The `prompt` field was added in design intent, folding the old
134/// `Fn(inv, directive)` `directive` argument into the invocation. The
135/// spawner is expected to call
136/// `engine.fetch_prompt(token, task_id, attempt)` in its prep step and
137/// inject the snapshot into the invocation (push form). The `WorkerFn`
138/// side may still re-pull if it needs to — for example to fetch the
139/// prompt for a different attempt.
140///
141/// The `sink` field was added in design intent as the formal contract for
142/// the spawner's intake surface. A worker fn can stream intermediate
143/// events with things like
144/// `inv.sink.emit(OutputEvent::Progress { .. })`. Child-process
145/// spawners (`ProcessSpawner`, etc.) do not use `sink` — the child
146/// speaks the stdout protocol; `InProcSpawner` injects one. Even
147/// without `sink`, the `WorkerResult` returned by the fn is still
148/// folded into a `Final` event on the spawner side, running alongside
149/// the older return-value path.
150/// `#[non_exhaustive]`: this struct is the in-process seam every backend
151/// reads task context off, so it is expected to keep growing (GH #86 added
152/// `context`). Marking it non-exhaustive makes each future field a
153/// non-breaking addition. Construct it with [`WorkerInvocation::new`] plus
154/// the `with_*` setters — the same shape `agent-block-core` moved
155/// `BlockConfig` to, and for the same reason.
156#[derive(Clone)]
157#[non_exhaustive]
158pub struct WorkerInvocation {
159 /// Capability token authorizing this attempt.
160 pub token: CapToken,
161 /// The task this invocation belongs to.
162 pub task_id: StepId,
163 /// Attempt number within the task (used to key output events).
164 pub attempt: u32,
165 /// Registered agent name the `WorkerFn` was looked up under.
166 pub agent: String,
167 /// The prompt/prep snapshot pulled via `engine.fetch_prompt`,
168 /// injected here (push form) so the worker fn does not need to call
169 /// back into the engine for the common case.
170 pub prompt: String,
171 /// Intake: sink the worker fn uses to emit intermediate
172 /// `OutputEvent`s. Injected by `InProcSpawner`. `None` means the
173 /// sink path is not wired for this invocation.
174 pub sink: Option<std::sync::Arc<dyn crate::worker::output::OutputSink>>,
175 /// Upstream task cancel token — the clone of `cancel_inner`
176 /// generated by `InProcSpawner` for `JoinHandleWorker`. Worker fns
177 /// bridge this to their child futures or their SDK's
178 /// `shutdown_token`, propagating external cancellation all the way
179 /// down. `None` — like `sink` above — means the caller path is not
180 /// carrying the cancel channel.
181 pub cancel_token: Option<tokio_util::sync::CancellationToken>,
182 /// The materialized, policy-applied task context for this attempt —
183 /// the **in-process twin of [`crate::types::WorkerPayload::context`]**
184 /// (which is how the same view reaches an out-of-process Operator over
185 /// `GET /v1/worker/prompt`).
186 ///
187 /// This is the single seam through which task-level context reaches an
188 /// in-process worker. `InProcSpawner::spawn` fills it once, from
189 /// [`AgentContextView::materialized_or_from_ctx`], so a worker fn reads
190 /// `inv.context` instead of hand-rolling its own `Ctx` peek — the
191 /// duplication that previously left the Lua / RustFn workers with no
192 /// context at all while each other backend re-derived its own subset.
193 ///
194 /// `None` means the caller path did not carry a `Ctx` (the same
195 /// "not wired for this invocation" convention `sink` / `cancel_token`
196 /// use above). It is never `None` on the `InProcSpawner` path.
197 pub context: Option<AgentContextView>,
198}
199
200impl WorkerInvocation {
201 /// The five fields every invocation must carry. The optional rails
202 /// (`sink` / `cancel_token` / `context`) default to `None` and are
203 /// added with the `with_*` setters below — `InProcSpawner::spawn`
204 /// wires all three.
205 pub fn new(
206 token: CapToken,
207 task_id: StepId,
208 attempt: u32,
209 agent: impl Into<String>,
210 prompt: impl Into<String>,
211 ) -> Self {
212 Self {
213 token,
214 task_id,
215 attempt,
216 agent: agent.into(),
217 prompt: prompt.into(),
218 sink: None,
219 cancel_token: None,
220 context: None,
221 }
222 }
223
224 /// Attach the intake sink (see the [`Self::sink`] field doc).
225 pub fn with_sink(
226 mut self,
227 sink: std::sync::Arc<dyn crate::worker::output::OutputSink>,
228 ) -> Self {
229 self.sink = Some(sink);
230 self
231 }
232
233 /// Attach the upstream cancel token (see the [`Self::cancel_token`]
234 /// field doc).
235 pub fn with_cancel_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
236 self.cancel_token = Some(token);
237 self
238 }
239
240 /// Attach the materialized task context (see the [`Self::context`]
241 /// field doc).
242 pub fn with_context(mut self, context: AgentContextView) -> Self {
243 self.context = Some(context);
244 self
245 }
246}
247
248impl std::fmt::Debug for WorkerInvocation {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 f.debug_struct("WorkerInvocation")
251 .field("token", &self.token)
252 .field("task_id", &self.task_id)
253 .field("attempt", &self.attempt)
254 .field("agent", &self.agent)
255 .field("prompt", &self.prompt)
256 .field("sink", &self.sink.as_ref().map(|_| "<OutputSink>"))
257 .field(
258 "cancel_token",
259 &self.cancel_token.as_ref().map(|_| "<CancellationToken>"),
260 )
261 .finish()
262 }
263}
264
265/// A registered agent implementation: takes a `WorkerInvocation` and
266/// resolves to a `WorkerResult` (or a `WorkerError`). Boxed as a
267/// type-erased `Future` so heterogeneous agent implementations (async
268/// fns, closures capturing state, etc.) can share one registry entry
269/// type.
270pub type WorkerFn = Arc<
271 dyn Fn(
272 WorkerInvocation,
273 ) -> Pin<Box<dyn Future<Output = Result<WorkerResult, WorkerError>> + Send>>
274 + Send
275 + Sync,
276>;
277
278/// `agent`-string → `WorkerFn` registry. The generic parameter `W` pins
279/// the per-kind Worker concrete type at the type level, so AgentBlock /
280/// Lua / RustFn each produce their own Worker type through
281/// `InProcSpawner<W>` and the type binding is preserved right up until
282/// `SpawnerAdapter::spawn()` erases the return as `Box<dyn Worker>`.
283/// `W` must be constructible from `WorkerJoinHandler` via `From` — i.e.
284/// a newtype that embeds the async-signal handle.
285pub struct InProcSpawner<W = crate::worker::MiddlewareWorker> {
286 /// Agent name → implementation lookup table.
287 pub registry: HashMap<String, WorkerFn>,
288 _phantom: std::marker::PhantomData<W>,
289}
290
291// Inherent impl for the default W = MiddlewareWorker (so `InProcSpawner::new()`
292// in existing tests picks this default).
293impl InProcSpawner {
294 /// Creates an empty registry, defaulting the Worker type to
295 /// `MiddlewareWorker` (used by existing call sites and tests).
296 pub fn new() -> Self {
297 Self {
298 registry: HashMap::new(),
299 _phantom: std::marker::PhantomData,
300 }
301 }
302
303 /// Registers a `WorkerFn`-shaped async closure under `agent`,
304 /// overwriting any previous registration for the same name. Returns
305 /// `&mut Self` for chained registration calls.
306 pub fn register<F, Fut>(&mut self, agent: impl Into<String>, f: F) -> &mut Self
307 where
308 F: Fn(WorkerInvocation) -> Fut + Send + Sync + 'static,
309 Fut: Future<Output = Result<WorkerResult, WorkerError>> + Send + 'static,
310 {
311 let f = Arc::new(f);
312 let wrapped: WorkerFn = Arc::new(move |inv| {
313 let f = f.clone();
314 Box::pin(f(inv))
315 });
316 self.registry.insert(agent.into(), wrapped);
317 self
318 }
319}
320
321// Generic typed impl (the factory.build path that constructs a per-kind Worker).
322impl<W> InProcSpawner<W>
323where
324 W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
325{
326 /// Creates an empty registry pinned to Worker type `W` (the
327 /// `factory.build` path uses this to get a per-kind Worker out of
328 /// `spawn()` instead of the default `MiddlewareWorker`).
329 pub fn typed() -> Self {
330 Self {
331 registry: HashMap::new(),
332 _phantom: std::marker::PhantomData,
333 }
334 }
335}
336
337impl Default for InProcSpawner {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343#[async_trait]
344impl<W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static> SpawnerAdapter
345 for InProcSpawner<W>
346{
347 async fn spawn(
348 &self,
349 engine: &Engine,
350 ctx: &Ctx,
351 task_id: StepId,
352 attempt: u32,
353 token: CapToken,
354 ) -> Result<Box<dyn Worker>, SpawnError> {
355 let f = self
356 .registry
357 .get(&ctx.agent)
358 .cloned()
359 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
360
361 // design intent: prompts are pulled via engine.fetch_prompt (the directive argument is retired)
362 let prompt = engine
363 .fetch_prompt(&token, &task_id)
364 .await
365 .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
366 // In-process WorkerInvocation consumes `prompt` as `String` (issue #18
367 // boundary render): Value flows end-to-end through the engine, and is
368 // stringified here for the RustFn / Lua worker.
369 let prompt = crate::core::engine::render_directive_to_string(&prompt);
370
371 let (tx, rx) = tokio::sync::oneshot::channel();
372 let cancel = tokio_util::sync::CancellationToken::new();
373 let cancel_inner = cancel.clone();
374 let worker_id = crate::types::WorkerId::new();
375 // issue #11: surface the minted WorkerId in the trace log.
376 tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (rustfn)");
377 // design intent: hand `engine` / `token` to the spawn task so it can emit
378 // OutputEvent::Final via submit_output (side-by-side with the
379 // WorkerResult oneshot path).
380 let engine_for_emit = engine.clone();
381 let token_for_emit = token.clone();
382 let task_id_for_emit = task_id.clone();
383 // Wire the receiving end by injecting an EngineSink into WorkerInvocation.sink.
384 let sink = std::sync::Arc::new(crate::worker::output::EngineSink::new(
385 engine.clone(),
386 token.clone(),
387 task_id.clone(),
388 attempt,
389 )) as std::sync::Arc<dyn crate::worker::output::OutputSink>;
390 let inv = WorkerInvocation::new(token, task_id, attempt, ctx.agent.clone(), prompt)
391 .with_sink(sink)
392 .with_cancel_token(cancel_inner.clone())
393 // The one place task-level context enters the in-process lane
394 // (mirrors how `Engine::fetch_worker_payload` fills
395 // `WorkerPayload.context` for the out-of-process lane). Reads
396 // the policy-applied view `AgentContextMiddleware` stashed, and
397 // degrades to the raw `Ctx` projection when that layer is not
398 // on this spawner stack.
399 .with_context(AgentContextView::materialized_or_from_ctx(ctx));
400
401 tokio::spawn(async move {
402 let result = tokio::select! {
403 r = f(inv) => r,
404 _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
405 };
406 // Fold WorkerResult into OutputEvent::Final. Contract: one Final per attempt.
407 //
408 // `submit_output` can REJECT this write — the GH #51
409 // completion-time verdict-contract check is embedded in it and
410 // runs BEFORE the `output_tail` append (see
411 // `Engine::verdict_contract_completion_check`). This used to be
412 // a discarded `let _ = …`, which made a rejection completely
413 // invisible on this lane: the worker still signalled success, so
414 // `dispatch_attempt_with`'s Final-pull reported the bare
415 // `no Final in output_tail` with no cause anywhere — not even a
416 // log line, while the WS Operator lane has always logged one
417 // (`crate::operator`'s fallback emit). Carry the rejection into
418 // the completion signal instead, so the reason travels all the
419 // way to `EngineError::DispatchFailed` and the author sees which
420 // contract they violated rather than a missing-Final symptom.
421 //
422 // Only the PRE-write rejections escalate to a failed attempt.
423 // `submit_output` also returns `Err` for post-write side
424 // effects (a strict-`CheckPolicy` materialize failure, say),
425 // and there the `Final` IS on the tail — the attempt has a
426 // value, the dispatcher can complete it, and failing it here
427 // would turn a fail-open projection miss into a dead step.
428 // Those still log, so the discarded-error hole is closed for
429 // both, but only the contract gate changes the outcome.
430 let mut emit_rejection: Option<String> = None;
431 if let Ok(wr) = &result {
432 // Stats sidecar: forward boundary-reported stats to the
433 // engine (drained by the dispatcher's outcome fold into
434 // the terminal StepEntry). This single fold site covers
435 // every InProc worker kind (RustFn / Lua / AgentBlock).
436 if let Some(stats) = wr.stats.clone() {
437 engine_for_emit
438 .record_worker_stats(&task_id_for_emit, attempt, stats)
439 .await;
440 }
441 let ev = crate::worker::output::OutputEvent::Final {
442 content: crate::worker::output::ContentRef::Inline {
443 value: wr.value.clone(),
444 },
445 ok: wr.ok,
446 };
447 if let Err(e) = engine_for_emit
448 .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
449 .await
450 {
451 let blocks_the_final = matches!(
452 e,
453 crate::EngineError::VerdictValueRejected { .. }
454 | crate::EngineError::VerdictPartMissing { .. }
455 );
456 tracing::warn!(
457 step_id = %task_id_for_emit,
458 attempt,
459 error = %e,
460 blocks_the_final,
461 "in-process worker's Final submission returned an error"
462 );
463 if blocks_the_final {
464 emit_rejection = Some(e.to_string());
465 }
466 }
467 }
468 let signal: Result<(), WorkerError> = match emit_rejection {
469 Some(reason) => Err(WorkerError::Failed(format!(
470 "Final rejected before output_tail: {reason}"
471 ))),
472 None => result.map(|_| ()),
473 };
474 let _ = tx.send(signal);
475 });
476
477 let handler = crate::worker::WorkerJoinHandler {
478 worker_id,
479 cancel,
480 completion: rx,
481 };
482 Ok(Box::new(W::from(handler)))
483 }
484}