Skip to main content

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}