Skip to main content

mlua_swarm/worker/agent_block/
runtime.rs

1//! [`AgentBlockInProcessSpawnerFactory`] — in-process headless LLM
2//! agent execution over the `agent-block-core` SDK.
3//!
4//! ## Design responsibility — a state-less factory
5//!
6//! The factory is a **kind-level general-purpose builder** — the
7//! process-wide infrastructure layer. It does not carry per-agent
8//! specialisation (script / `system_prompt` / tools); all agent
9//! specialisation belongs to `AgentDef.spec` + `AgentDef.profile`. The
10//! old `default_script_path` / `default_project_root` fields were
11//! removed — they were the collision source when a single process
12//! hosts multiple agent.md files.
13//!
14//! ## Two modes (via `ScriptSource`, v0.27.0)
15//!
16//! | Mode | Trigger | Path |
17//! |---|---|---|
18//! | **PromptBasedAgent** (default) | `spec.script_path` absent | `ScriptSource::DefaultAgent` — the SDK's embedded invoker (the `agent` StdPkg module invoked with `_PROMPT` / `_CONTEXT`); event kind = `agent_result`. |
19//! | **ScriptBasedAgent** | `spec.script_path = "<path>"` | `ScriptSource::Path(...)` — a caller-provided Lua script; event kind = `worker_result`. |
20//!
21//! `profile.system_prompt` (the agent.md body) is injected into the
22//! `_CONTEXT` Lua global through `BlockConfig.context`, and applies to
23//! both modes.
24//!
25//! ## Spec shape (`AgentDef.spec`)
26//!
27//! ```jsonc
28//! {
29//!   "project_root": "<path>",          // optional, default = std::env::current_dir()
30//!   "script_path": "<path>",           // optional; absent => ScriptSource::DefaultAgent (PromptBased)
31//!   "mcp_rpc_timeout_ms": 30000        // optional, default = 30s
32//! }
33//! ```
34//!
35//! ## `project_root` resolution (issue #17, GH #20)
36//!
37//! `spec.project_root` (above) is only the **compile-time fallback**
38//! tier — resolved once in [`AgentBlockInProcessSpawnerFactory::build`],
39//! before any `Ctx` exists. At spawn time,
40//! [`AgentBlockCtxAwareSpawner::spawn`] re-resolves the actual worker
41//! cwd per invocation through a materialized
42//! [`crate::core::agent_context::AgentContextView`]
43//! (`AgentContextView::materialized_or_from_ctx`, GH #20 Contract C —
44//! see that module's doc for the full narrative) with this priority
45//! (highest first):
46//!
47//! 1. `view.work_dir` — Task-level, set by `TaskInputMiddleware` from
48//!    the launch's `init_ctx.work_dir`.
49//! 2. `view.project_root` — same middleware, `init_ctx.project_root`.
50//! 3. `spec.project_root` / `std::env::current_dir()` (the compile-time
51//!    fallback baked into [`AgentBlockSettings`] above).
52//!
53//! This lets a single Blueprint's `AgentDef.spec.project_root` (fixed at
54//! compile time) be overridden per task launch, so the same Blueprint
55//! can run against different caller-supplied project roots without a
56//! `spec` edit. GH #20 additionally threads `view.task_metadata` into
57//! `AgentBlockSettings::task_metadata` — the pull path this axis
58//! previously lacked entirely.
59//!
60//! ## SDK paths introduced from v0.22.0 through v0.27.0
61//!
62//! | Version | Feature | Use case |
63//! |---|---|---|
64//! | v0.22.0 | `bus.emit(kind, payload, id?)` Lua bridge | script → host event push |
65//! | v0.23.0 | `BlockConfig.host_handlers` | Pre-install a Rust handler on the EventBus |
66//! | v0.24.0 | `BlockConfig.auto_serve_bus` | SDK embed drives the dispatcher in the background |
67//! | v0.25.0 | `BlockConfig.shutdown_token` + `BlockError::Cancelled` + `Send` on `run()` | `tokio::spawn` and external cancel |
68//! | v0.26.0 | `ScriptSource` / `PromptSource` / `SecretKeySource` enums plus the embedded `DefaultAgent` invoker (breaking) | Script becomes optional at the SDK level |
69//! | v0.27.0 | Embed the `compile_loop` StdPkg into core | `require("compile_loop")` hits directly |
70
71use crate::core::agent_context::AgentContextView;
72use crate::core::ctx::Ctx;
73use crate::core::engine::Engine;
74use crate::types::{CapToken, StepId};
75use crate::worker::adapter::{
76    InProcSpawner, SpawnError, SpawnerAdapter, WorkerError, WorkerInvocation, WorkerResult,
77};
78use crate::worker::Worker;
79use agent_block_core::bus::dispatcher::Handler;
80use agent_block_core::host::{PromptSource, ScriptSource};
81use agent_block_core::{run, BlockConfig};
82use agent_block_types::error::BlockError;
83use async_trait::async_trait;
84use serde_json::Value;
85use std::collections::HashMap;
86use std::path::PathBuf;
87use std::sync::{Arc, Mutex};
88use std::time::Duration;
89use tokio::sync::oneshot;
90
91/// Host-side handler that fires when the Lua script (or the
92/// DefaultAgent invoker) calls `bus.emit(<kind>, payload)`. It folds
93/// the payload into a [`WorkerResult`] and forwards it on the
94/// [`oneshot::Sender`].
95///
96/// This is **an AgentBlock-internal helper**. Different SDK paths use
97/// different event names and payload shapes — the DefaultAgent
98/// invoker's `agent_result` event carries the entire `agent.run`
99/// return value (`{content, messages, num_turns, ok, usage}`), while a
100/// caller script's `worker_result` event carries `{ok, response}`. The
101/// captor keeps those quirks contained and **normalises them**, so
102/// callers (flow.ir, the engine, higher-level Workers) always see the
103/// same single form: "the raw LLM response is `WorkerResult.value`".
104///
105/// Value extraction priority (the normalisation policy that hides the
106/// SDK quirks):
107///
108/// 1. `payload.content` — from the DefaultAgent invoker / `agent.run`
109///    return value; carried as a string.
110/// 2. `payload.response` — the caller script's `worker_result`
111///    convention; free-form.
112/// 3. Fallback: the whole payload — for custom shapes that carry
113///    neither of the above.
114///
115/// `ok` extraction: `payload.ok` if present, otherwise `true` — the
116/// DefaultAgent invoker includes `ok`, so this recovers it.
117///
118/// This is the core of the observation #2 fix. The previous
119/// implementation did not consult (1); it only fell back
120/// `(2) → (3)`. On the DefaultAgent path that pushed the whole
121/// `agent_result` object into `WorkerResult.value`, which then rode
122/// through the chain and hit the next step's prompt via
123/// JSON-stringification — burning 50-60% of the tokens on
124/// boilerplate. Pulling out (1) first normalises the chain to a single
125/// LLM raw-text carry and brings the Worker pattern up to the token
126/// efficiency of the Phase 3 WS Operator path.
127struct WorkerResultCaptor {
128    tx: Mutex<Option<oneshot::Sender<WorkerResult>>>,
129}
130
131impl WorkerResultCaptor {
132    /// SDK-quirks normalisation: extract `(value, ok)` from a
133    /// `bus.emit` payload. `pub(crate)` so both callers and unit tests
134    /// can reach it.
135    fn extract(payload: &Value) -> (Value, bool) {
136        let ok = payload.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
137        let value = payload
138            .get("content")
139            .cloned()
140            .or_else(|| payload.get("response").cloned())
141            .unwrap_or_else(|| payload.clone());
142        (value, ok)
143    }
144
145    /// Stats-sidecar extraction (per-step run stats): the DefaultAgent
146    /// invoker's `agent_result` payload carries the full `agent.run`
147    /// return, whose `usage` (`{input_tokens, output_tokens,
148    /// total_tokens}`, all turns summed) and `num_turns` used to be
149    /// DROPPED here — the exact gap this recovers. `None` when the
150    /// payload carries neither (caller-script `worker_result` shapes).
151    /// The raw `usage` object also rides as `adapter_data` so
152    /// provider-specific detail (cache tokens etc.) survives.
153    fn extract_stats(payload: &Value) -> Option<crate::store::trace::WorkerStats> {
154        let usage_raw = payload.get("usage");
155        let usage = usage_raw.and_then(|u| {
156            let input = u.get("input_tokens").and_then(|v| v.as_u64());
157            let output = u.get("output_tokens").and_then(|v| v.as_u64());
158            match (input, output) {
159                (Some(i), Some(o)) => Some(crate::store::trace::TokenUsage {
160                    input_tokens: i,
161                    output_tokens: o,
162                    total_tokens: u
163                        .get("total_tokens")
164                        .and_then(|v| v.as_u64())
165                        .unwrap_or(i + o),
166                }),
167                _ => None,
168            }
169        });
170        let num_turns = payload
171            .get("num_turns")
172            .and_then(|v| v.as_u64())
173            .map(|n| n as u32);
174        if usage.is_none() && num_turns.is_none() {
175            return None;
176        }
177        Some(crate::store::trace::WorkerStats {
178            worker_kind: Some("agent_block".to_string()),
179            model: None,
180            usage,
181            num_turns,
182            adapter_data: usage_raw.cloned(),
183        })
184    }
185}
186
187#[async_trait]
188impl Handler for WorkerResultCaptor {
189    async fn call(
190        &self,
191        _kind: String,
192        _id: String,
193        payload: Value,
194        _meta: Value,
195    ) -> Result<Value, BlockError> {
196        let (value, ok) = Self::extract(&payload);
197        let stats = Self::extract_stats(&payload);
198        // Even when the SDK payload carries no usage (script-side
199        // `worker_result` shapes), the boundary still knows its own
200        // kind — surface it so `StepEntry.worker_kind` is never empty.
201        let wr = WorkerResult { value, ok, stats }.ensure_worker_kind("agent_block");
202        if let Ok(mut guard) = self.tx.lock() {
203            if let Some(tx) = guard.take() {
204                let _ = tx.send(wr);
205            }
206        }
207        Ok(Value::Null)
208    }
209}
210
211/// Settings baked per `AgentDef` — the static portion of one
212/// invocation.
213///
214/// v0.28.0 adopted `BlockConfig.host_handler` (a kind-agnostic
215/// single sink backed by `EventBus::on_any`); the older
216/// `result_event_kind: String` field (which required the caller /
217/// script to coordinate a kind string) is gone. One captor per
218/// invocation is enough, so a single sink is enough.
219#[derive(Clone)]
220struct AgentBlockSettings {
221    /// Either a PromptBasedAgent — `ScriptSource::Inline` with an
222    /// in-line invoker that embeds `mcp_servers` — or a
223    /// ScriptBasedAgent (`ScriptSource::Path(...)`, a caller-supplied
224    /// script).
225    script: ScriptSource,
226    project_root: PathBuf,
227    mcp_rpc_timeout: Duration,
228    /// Agent persona — the `system_prompt` composed from the agent.md
229    /// body and frontmatter. `None` maps to `BlockConfig.context = None`
230    /// for backwards compatibility with the old path.
231    profile_context: Option<String>,
232    /// GH #20 Contract C: `AgentContextView.task_metadata`, re-resolved
233    /// per invocation alongside `project_root` (see
234    /// [`AgentBlockCtxAwareSpawner::resolve_settings`]). `None` at
235    /// compile time (no `Ctx` exists yet in
236    /// [`AgentBlockInProcessSpawnerFactory::build`]) and whenever the
237    /// materialized view carries no task metadata. This is the pull
238    /// path the in-process AgentBlock axis previously lacked entirely —
239    /// the field exists here so a future consumption point (script /
240    /// env sink) has somewhere to read it from; wiring one is a future
241    /// issue.
242    task_metadata: Option<Value>,
243}
244
245/// One invocation's worth of an `agent-block-core` SDK call — the
246/// `WorkerFn` body.
247///
248/// Registers the result captor through the v0.28.0 `host_handler`
249/// (single, kind-agnostic fallback). The plural `host_handlers`
250/// (string-keyed routing) is not needed — one captor per invocation is
251/// enough, and there is no script-side event-kind string to coordinate.
252async fn run_agent_block_worker(
253    settings: Arc<AgentBlockSettings>,
254    inv: WorkerInvocation,
255) -> Result<WorkerResult, WorkerError> {
256    let (tx, rx) = oneshot::channel();
257    let captor: Arc<dyn Handler> = Arc::new(WorkerResultCaptor {
258        tx: Mutex::new(Some(tx)),
259    });
260
261    // Bridge the shutdown token: forward `WorkerInvocation.cancel_token`
262    // into the SDK's `shutdown_token` if one is set; otherwise use a
263    // fresh token (no external cancel).
264    let shutdown_token = inv.cancel_token.clone().unwrap_or_default();
265    let config = BlockConfig {
266        script: settings.script.clone(),
267        project_root: settings.project_root.clone(),
268        relay_url: None,
269        secret_key: None,
270        mcp_rpc_timeout: settings.mcp_rpc_timeout,
271        prompt: Some(PromptSource::Inline(inv.prompt)),
272        context: settings.profile_context.clone().map(PromptSource::Inline),
273        host_handlers: HashMap::new(),
274        host_handler: Some(captor),
275        auto_serve_bus: true,
276        shutdown_token: Some(shutdown_token.clone()),
277    };
278
279    let run_handle = tokio::spawn(run(config));
280    let run_result = run_handle
281        .await
282        .map_err(|e| WorkerError::Failed(format!("agent-block task join: {e}")))?;
283    run_result.map_err(|e| WorkerError::Failed(format!("agent-block run failed: {e}")))?;
284
285    rx.await.map_err(|_| {
286        WorkerError::Failed("agent-block script finished without emitting result via bus".into())
287    })
288}
289
290// ─── tools / mcp_servers resolution ───────────────────────────────────────
291
292/// Cross-reference `profile.tools` (the CSV on the `tools:` line of an
293/// agent.md frontmatter) with `spec.mcp_servers` (the `"server name" →
294/// command + args` mapping provided by the `AgentDef` literal cascade)
295/// and resolve the `mcp_servers` config actually exposed to the LLM
296/// for this invocation.
297///
298/// Algorithm:
299///
300/// 1. Extract `mcp__<server>__<tool>` patterns from `profile.tools`;
301///    collect the `<server>` names.
302/// 2. Filter `spec.mcp_servers` to just the entries whose name is in
303///    that set.
304///
305/// This is the response to observation #3 — do not hand the LLM
306/// `mcp_servers` it does not need (only the servers the profile
307/// explicitly asks for), and equally do not expose servers the
308/// profile does not know about even if the spec carries them
309/// (caller intent wins).
310///
311/// CC built-in tools (non-`mcp__`-prefixed names like `Read` / `Write`
312/// / `WebSearch`) are out of scope here; handling those lives in a
313/// different layer — a carry that would come through a future
314/// `opts.extra_tools` Rust implementation.
315pub fn resolve_needed_mcp_servers(
316    profile_tools: &[String],
317    spec_mcp_servers: &[Value],
318) -> Vec<Value> {
319    use std::collections::HashSet;
320    // Step 1: server names from `mcp__<server>__<tool>` patterns in
321    // `profile.tools`.
322    let needed: HashSet<&str> = profile_tools
323        .iter()
324        .filter_map(|t| {
325            let rest = t.strip_prefix("mcp__")?;
326            // Split `<server>__<tool>` at the first `__`.
327            let idx = rest.find("__")?;
328            Some(&rest[..idx])
329        })
330        .collect();
331
332    // Step 2: filter `spec.mcp_servers` down to entries whose name is
333    // in `needed`.
334    spec_mcp_servers
335        .iter()
336        .filter(|cfg| {
337            cfg.get("name")
338                .and_then(|n| n.as_str())
339                .map(|name| needed.contains(name))
340                .unwrap_or(false)
341        })
342        .cloned()
343        .collect()
344}
345
346/// Build the inline Lua script used on the PromptBasedAgent path (when
347/// `spec.script_path` is absent). Instead of the SDK's embedded
348/// `DEFAULT_AGENT_INVOKER` (which passes no tools), this embeds
349/// `mcp_servers` as a Lua literal table and hands it to `agent.run`.
350///
351/// This is the core of the observation #3 fix. The old DefaultAgent
352/// path had no way to deliver a frontmatter `tools:` line to the SDK.
353/// This inline path bakes the `profile.tools` → `mcp_servers` config
354/// into the Lua source, so the LLM can actually make tool calls.
355///
356/// The JSON-stringify + `std.json.decode` route was ruled out because
357/// the SDK environment cannot `require` the `std` module (no
358/// `package.preload['std']` field), so we take the JSON → Lua-literal
359/// conversion on the Rust side and embed the result directly. The
360/// event name is `agent_result` — the same convention the SDK's
361/// internal `DEFAULT_AGENT_INVOKER` uses.
362pub fn build_inline_agent_invoker(mcp_servers: &[Value]) -> ScriptSource {
363    let mcp_lua = json_array_to_lua_literal(mcp_servers);
364    let source = format!(
365        r##"local agent = require("agent")
366local mcp_servers = {mcp_lua}
367local r = agent.run({{
368    prompt = _PROMPT,
369    system = _CONTEXT,
370    mcp_servers = mcp_servers,
371}})
372bus.emit("agent_result", r)
373"##
374    );
375    ScriptSource::Inline {
376        source,
377        name: "mlua_swarm_engine_default_agent_invoker.lua".into(),
378    }
379}
380
381/// Convert a JSON `Value` into a Lua literal expression, for embedding
382/// into the inline script. Lua string escaping is delegated to Rust's
383/// `{:?}` `Debug` output — Lua syntax is compatible with the escapes
384/// it produces (`"`, `\\`, `\n`, `\r`, `\t`, and so on). Edge cases
385/// like `\0` or unusual Unicode escapes are outside the scope of this
386/// use.
387fn json_to_lua_literal(v: &Value) -> String {
388    match v {
389        Value::Null => "nil".to_string(),
390        Value::Bool(b) => b.to_string(),
391        Value::Number(n) => n.to_string(),
392        Value::String(s) => format!("{s:?}"),
393        Value::Array(arr) => {
394            let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
395            format!("{{{}}}", items.join(", "))
396        }
397        Value::Object(map) => {
398            let items: Vec<String> = map
399                .iter()
400                .map(|(k, v)| format!("[{k:?}]={}", json_to_lua_literal(v)))
401                .collect();
402            format!("{{{}}}", items.join(", "))
403        }
404    }
405}
406
407/// Convert a `Vec<Value>` into a Lua literal sequence. An empty array
408/// becomes `{}` — a Lua empty table.
409fn json_array_to_lua_literal(arr: &[Value]) -> String {
410    if arr.is_empty() {
411        return "{}".to_string();
412    }
413    let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
414    format!("{{{}}}", items.join(", "))
415}
416
417// ─── SpawnerFactory ───────────────────────────────────────────────────────
418
419/// The compile-time (`spec` / `env::current_dir()`) fallback tier of the
420/// `project_root` priority chain (issue #17) — the tail two links of
421/// **`ctx.meta.runtime` `work_dir` > `ctx.meta.runtime` `project_root` >
422/// `spec.project_root` > `env::current_dir()`**. Extracted as a standalone
423/// pure fn so it is independently testable without needing a full `Ctx` /
424/// `SpawnerAdapter` round-trip.
425fn resolve_spec_project_root(spec: &Value) -> PathBuf {
426    match spec.get("project_root").and_then(|v| v.as_str()) {
427        Some(s) => PathBuf::from(s),
428        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
429    }
430}
431
432/// The `SpawnerFactory` for AgentBlock. `KIND = AgentKind::AgentBlock`.
433///
434/// **State-less.** One factory per process; every `AgentDef` uses it
435/// as a shared builder. Per-agent specialisation stays **entirely
436/// inside `AgentDef.spec` + `AgentDef.profile`** — the old
437/// `default_script_path` / `default_project_root` fields are gone.
438///
439/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` — an
440/// AgentBlock worker on the InProcess adapter.
441pub struct AgentBlockInProcessSpawnerFactory;
442
443impl Default for AgentBlockInProcessSpawnerFactory {
444    fn default() -> Self {
445        Self
446    }
447}
448
449impl AgentBlockInProcessSpawnerFactory {
450    /// Stateless constructor — equivalent to `Default::default()`.
451    pub fn new() -> Self {
452        Self
453    }
454}
455
456impl crate::blueprint::compiler::SpawnerFactoryKind for AgentBlockInProcessSpawnerFactory {
457    const KIND: crate::blueprint::AgentKind = crate::blueprint::AgentKind::AgentBlock;
458    type Worker = AgentBlockWorker;
459}
460
461impl crate::blueprint::compiler::SpawnerFactory for AgentBlockInProcessSpawnerFactory {
462    fn build(
463        &self,
464        agent_def: &crate::blueprint::AgentDef,
465        _hint: Option<&Value>,
466    ) -> Result<
467        Arc<dyn crate::worker::adapter::SpawnerAdapter>,
468        crate::blueprint::compiler::CompileError,
469    > {
470        let agent_name = agent_def.name.clone();
471        let spec = &agent_def.spec;
472
473        // Resolve the actual mcp_servers config to pass to the real LLM by
474        // combining profile.tools (the `tools:` line of the agent.md
475        // frontmatter) with spec.mcp_servers (the first axis of AgentDef
476        // literal cascade — a "server name → command + args" mapping). The
477        // result is JSON-embedded into the Lua source by
478        // build_inline_agent_invoker and flows into `agent.run({mcp_servers=...})`.
479        let profile_tools: Vec<String> = agent_def
480            .profile
481            .as_ref()
482            .map(|p| p.tools.clone())
483            .unwrap_or_default();
484        let spec_mcp_servers: Vec<Value> = spec
485            .get("mcp_servers")
486            .and_then(|v| v.as_array())
487            .cloned()
488            .unwrap_or_default();
489        let needed_mcp_servers = resolve_needed_mcp_servers(&profile_tools, &spec_mcp_servers);
490
491        // script: `spec.script_path` absent → PromptBasedAgent (the new Inline
492        //         path, embedding tools and calling agent.run); present →
493        //         ScriptBasedAgent (a caller-provided script path where tools
494        //         are the caller's responsibility). Event-kind string
495        //         dependency was retired — the `host_handler` single sink
496        //         captures every kind.
497        let script = match spec.get("script_path").and_then(|v| v.as_str()) {
498            Some(s) => ScriptSource::Path(PathBuf::from(s)),
499            None => build_inline_agent_invoker(&needed_mcp_servers),
500        };
501
502        // issue #17: this is the compile-time fallback tier only —
503        // `spec.project_root`, then `env::current_dir()`. No `Ctx` exists
504        // yet at `build()` time, so the higher-priority
505        // `ctx.meta.runtime` tier cannot be consulted here; it is
506        // re-resolved per invocation by `AgentBlockCtxAwareSpawner::spawn`
507        // below (see the module-level "`project_root` resolution" doc).
508        let project_root = resolve_spec_project_root(spec);
509        let mcp_rpc_timeout = match spec.get("mcp_rpc_timeout_ms").and_then(|v| v.as_u64()) {
510            Some(ms) => Duration::from_millis(ms),
511            None => Duration::from_secs(30),
512        };
513        let profile_context = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
514
515        let base_settings = Arc::new(AgentBlockSettings {
516            script,
517            project_root,
518            mcp_rpc_timeout,
519            profile_context,
520            // GH #20: no `Ctx` exists yet at compile time — the ctx-aware
521            // tier is re-resolved per invocation in `resolve_settings`.
522            task_metadata: None,
523        });
524
525        Ok(Arc::new(AgentBlockCtxAwareSpawner {
526            agent_name,
527            base_settings,
528        }))
529    }
530}
531
532/// `SpawnerAdapter` wrapper that re-resolves this AgentBlock invocation's
533/// `project_root` from `ctx.meta.runtime` at spawn time (issue #17),
534/// honoring the **`ctx.meta.runtime` `work_dir` > `ctx.meta.runtime`
535/// `project_root` > `spec.project_root` > `env::current_dir()`**
536/// priority chain — see the module-level "`project_root` resolution"
537/// doc for the full rationale.
538///
539/// `AgentBlockInProcessSpawnerFactory::build` bakes the `spec`/`env`
540/// fallback tier into `base_settings` at compile time, since no `Ctx`
541/// exists yet at that point. This wrapper is the first place a `Ctx` —
542/// and therefore `ctx.meta.runtime` — becomes available: `spawn()` is
543/// the per-attempt entry point every `SpawnerAdapter` implements.
544///
545/// Delegates the actual invocation to a freshly built
546/// `InProcSpawner<AgentBlockWorker>` holding a single `agent_name`
547/// registry entry closed over the per-invocation-resolved settings —
548/// the same shape `AgentBlockInProcessSpawnerFactory::build` used to
549/// build directly, just constructed per spawn instead of once at
550/// compile time (this factory produces exactly one route per
551/// `AgentDef`, so a 1-entry registry built fresh per call carries no
552/// meaningful overhead over the old compile-time-built one).
553struct AgentBlockCtxAwareSpawner {
554    /// The agent name this route serves (`AgentDef.name`, same value the
555    /// compiled `CompiledAgentTable` looks this adapter up under).
556    agent_name: String,
557    /// Compile-time-resolved fallback settings (script / mcp timeout /
558    /// profile context are invocation-invariant; `project_root` here is
559    /// the `spec` / `env::current_dir()` tail of the priority chain).
560    base_settings: Arc<AgentBlockSettings>,
561}
562
563impl AgentBlockCtxAwareSpawner {
564    /// Applies the `AgentContextView` override tier on top of
565    /// `base_settings.project_root` / `.task_metadata` (GH #20 Contract
566    /// C — materializes the view via
567    /// `AgentContextView::materialized_or_from_ctx`, then reads
568    /// `work_dir` / `project_root` / `task_metadata` off it instead of
569    /// pulling individual `ctx.meta.runtime` keys). `work_dir` outranks
570    /// `project_root` (it names the exact directory this specific
571    /// worker should run from); either, if present, overrides the
572    /// compile-time `project_root` baseline entirely. `task_metadata`
573    /// (when present on the view) always overrides the compile-time
574    /// `None` baseline — there is no compile-time equivalent to
575    /// override. Neither `work_dir` nor `project_root` present AND no
576    /// `task_metadata` → the baseline settings are reused as-is (no
577    /// clone).
578    fn resolve_settings(&self, ctx: &Ctx) -> Arc<AgentBlockSettings> {
579        let view = AgentContextView::materialized_or_from_ctx(ctx);
580        let override_path = view.work_dir.as_deref().or(view.project_root.as_deref());
581        if override_path.is_none() && view.task_metadata.is_none() {
582            return self.base_settings.clone();
583        }
584        let mut settings = (*self.base_settings).clone();
585        if let Some(p) = override_path {
586            settings.project_root = PathBuf::from(p);
587        }
588        if let Some(meta) = view.task_metadata {
589            settings.task_metadata = Some(meta);
590        }
591        Arc::new(settings)
592    }
593}
594
595#[async_trait]
596impl SpawnerAdapter for AgentBlockCtxAwareSpawner {
597    async fn spawn(
598        &self,
599        engine: &Engine,
600        ctx: &Ctx,
601        task_id: StepId,
602        attempt: u32,
603        token: CapToken,
604    ) -> Result<Box<dyn Worker>, SpawnError> {
605        let settings = self.resolve_settings(ctx);
606        let worker_fn: crate::worker::adapter::WorkerFn = Arc::new(move |inv| {
607            let settings = settings.clone();
608            Box::pin(run_agent_block_worker(settings, inv))
609        });
610        let mut sp: InProcSpawner<AgentBlockWorker> = InProcSpawner::<AgentBlockWorker>::typed();
611        sp.registry.insert(self.agent_name.clone(), worker_fn);
612        sp.spawn(engine, ctx, task_id, attempt, token).await
613    }
614}
615
616/// Concrete Worker type for the AgentBlock kind — the handle for an
617/// LLM call routed through the `agent-block-core` SDK. Embeds a
618/// `WorkerJoinHandler` to carry the async signal. The intent is to
619/// eventually keep the SDK-specific quirks — the `agent_result` event
620/// name, payload shape, shutdown-token bridging, agent_result.content
621/// normalisation — contained inside this struct. Today it lands as a
622/// thin shape holding only the async signal; Phase B adds the
623/// normalisation layer here and structurally eliminates the
624/// token-boilerplate waste observed in observation #2.
625pub struct AgentBlockWorker {
626    /// The completion-signal handle for this agent-block SDK call's
627    /// spawned task.
628    pub handler: crate::worker::WorkerJoinHandler,
629}
630
631impl From<crate::worker::WorkerJoinHandler> for AgentBlockWorker {
632    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
633        Self { handler }
634    }
635}
636
637#[async_trait]
638impl crate::worker::Worker for AgentBlockWorker {
639    fn id(&self) -> &crate::types::WorkerId {
640        &self.handler.worker_id
641    }
642    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
643        self.handler.cancel.clone()
644    }
645    async fn join(self: Box<Self>) -> Result<(), WorkerError> {
646        self.handler.await_completion().await
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use crate::core::agent_context::{TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
654
655    #[test]
656    fn resolve_needed_mcp_servers_filters_by_tool_prefix() {
657        let tools = vec![
658            "mcp__semantic-scholar__search_papers".to_string(),
659            "mcp__semantic-scholar__get_paper".to_string(),
660            "Read".to_string(),
661            "mcp__outline__list_docs".to_string(),
662            "WebSearch".to_string(),
663        ];
664        let spec_servers = vec![
665            serde_json::json!({"name": "semantic-scholar", "command": "ss-mcp", "args": []}),
666            serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []}),
667            serde_json::json!({"name": "unused", "command": "nope", "args": []}),
668        ];
669        let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
670        assert_eq!(needed.len(), 2, "got: {needed:?}");
671        let names: Vec<&str> = needed
672            .iter()
673            .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
674            .collect();
675        assert!(names.contains(&"semantic-scholar"));
676        assert!(names.contains(&"outline"));
677        assert!(!names.contains(&"unused"), "unused server is filtered out");
678    }
679
680    #[test]
681    fn resolve_needed_mcp_servers_returns_empty_when_no_mcp_tools() {
682        let tools = vec!["Read".to_string(), "WebSearch".to_string()];
683        let spec_servers =
684            vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
685        let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
686        assert!(
687            needed.is_empty(),
688            "no mcp__-prefixed tools → empty result, got: {needed:?}"
689        );
690    }
691
692    #[test]
693    fn build_inline_agent_invoker_embeds_mcp_servers_as_lua_literal() {
694        let servers =
695            vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
696        let script = build_inline_agent_invoker(&servers);
697        match script {
698            ScriptSource::Inline { source, name } => {
699                assert!(name.ends_with(".lua"));
700                assert!(source.contains("require(\"agent\")"));
701                assert!(source.contains("mcp_servers = mcp_servers"));
702                assert!(source.contains("bus.emit(\"agent_result\""));
703                // Lua literal embed (= keys [\"name\"]=\"outline\" form)
704                assert!(source.contains("[\"name\"]=\"outline\""));
705                assert!(source.contains("[\"command\"]=\"outline-mcp\""));
706                assert!(source.contains("[\"args\"]={}"), "args empty array literal");
707            }
708            other => panic!("expected Inline, got: {other:?}"),
709        }
710    }
711
712    #[test]
713    fn build_inline_agent_invoker_with_empty_servers_still_valid() {
714        let script = build_inline_agent_invoker(&[]);
715        match script {
716            ScriptSource::Inline { source, .. } => {
717                assert!(source.contains("local mcp_servers = {}"));
718            }
719            other => panic!("expected Inline, got: {other:?}"),
720        }
721    }
722
723    #[test]
724    fn json_to_lua_literal_handles_primitives_and_nested() {
725        assert_eq!(json_to_lua_literal(&serde_json::json!(null)), "nil");
726        assert_eq!(json_to_lua_literal(&serde_json::json!(true)), "true");
727        assert_eq!(json_to_lua_literal(&serde_json::json!(42)), "42");
728        assert_eq!(json_to_lua_literal(&serde_json::json!("hi")), "\"hi\"");
729        assert_eq!(
730            json_to_lua_literal(&serde_json::json!(["a", "b"])),
731            "{\"a\", \"b\"}"
732        );
733        assert_eq!(
734            json_to_lua_literal(&serde_json::json!({"k": 1})),
735            "{[\"k\"]=1}"
736        );
737    }
738
739    #[test]
740    fn extract_prefers_content_then_response_then_whole() {
741        // (1) `content` takes priority (DefaultAgent invoker / agent.run return-value path).
742        let p = serde_json::json!({
743            "content": "Water boils at 100°C",
744            "messages": [{"role": "assistant"}],
745            "usage": {"input_tokens": 67, "output_tokens": 29},
746            "ok": true,
747        });
748        let (value, ok) = WorkerResultCaptor::extract(&p);
749        assert_eq!(value, serde_json::json!("Water boils at 100°C"));
750        assert!(ok);
751
752        // (2) No `content` → `response` (caller-script convention worker_result).
753        let p = serde_json::json!({ "ok": false, "response": {"patch": "..."} });
754        let (value, ok) = WorkerResultCaptor::extract(&p);
755        assert_eq!(value, serde_json::json!({"patch": "..."}));
756        assert!(!ok);
757
758        // (3) Neither present → the whole payload (custom shape).
759        let p = serde_json::json!({ "custom_field": 42 });
760        let (value, ok) = WorkerResultCaptor::extract(&p);
761        assert_eq!(value, serde_json::json!({"custom_field": 42}));
762        assert!(ok); // `ok` absent → defaults to true
763    }
764
765    #[tokio::test]
766    async fn captor_emits_worker_result_from_payload() {
767        let (tx, rx) = oneshot::channel();
768        let captor = WorkerResultCaptor {
769            tx: Mutex::new(Some(tx)),
770        };
771        let payload = serde_json::json!({ "ok": true, "response": "hello" });
772        let ack = captor
773            .call("worker_result".into(), "evt-1".into(), payload, Value::Null)
774            .await
775            .expect("handler ack");
776        assert_eq!(ack, Value::Null);
777        let wr = rx.await.expect("recv");
778        assert!(wr.ok);
779        assert_eq!(wr.value, serde_json::json!("hello"));
780    }
781
782    #[tokio::test]
783    async fn factory_builds_prompt_based_agent_when_script_path_absent() {
784        use crate::blueprint::compiler::SpawnerFactory;
785        use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
786
787        let factory = AgentBlockInProcessSpawnerFactory::new();
788        let ad = AgentDef {
789            name: "writer".into(),
790            kind: AgentKind::AgentBlock,
791            spec: serde_json::json!({}),
792            profile: Some(AgentProfile {
793                system_prompt: "You are writer.".into(),
794                ..Default::default()
795            }),
796            meta: None,
797            runner: None,
798            runner_ref: None,
799            verdict: None,
800        };
801        let _spawner = factory.build(&ad, None).expect("factory build");
802        // = ScriptSource::Inline path (self-hosted invoker, mcp_servers embed);
803        // the host_handler single sink captures every event kind.
804    }
805
806    #[tokio::test]
807    async fn factory_builds_script_based_agent_when_script_path_present() {
808        use crate::blueprint::compiler::SpawnerFactory;
809        use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
810
811        let factory = AgentBlockInProcessSpawnerFactory::new();
812        let ad = AgentDef {
813            name: "patch-spawner".into(),
814            kind: AgentKind::AgentBlock,
815            spec: serde_json::json!({
816                "script_path": "assets/operator_scripts/blueprint_patch_spawner.lua",
817                "project_root": ".",
818            }),
819            profile: Some(AgentProfile {
820                system_prompt: "Patch generator.".into(),
821                ..Default::default()
822            }),
823            meta: None,
824            runner: None,
825            runner_ref: None,
826            verdict: None,
827        };
828        let _spawner = factory.build(&ad, None).expect("factory build");
829        // = ScriptSource::Path path; caller-provided script; host_handler single sink.
830    }
831
832    // ─── Issue #17: `project_root` priority chain ─────────────────────────
833
834    #[test]
835    fn resolve_spec_project_root_uses_spec_value_when_present() {
836        let resolved =
837            resolve_spec_project_root(&serde_json::json!({ "project_root": "/spec-root" }));
838        assert_eq!(resolved, PathBuf::from("/spec-root"));
839    }
840
841    #[test]
842    fn resolve_spec_project_root_falls_back_to_env_current_dir_when_spec_absent() {
843        let resolved = resolve_spec_project_root(&serde_json::json!({}));
844        assert_eq!(
845            resolved,
846            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
847        );
848    }
849
850    fn test_settings(project_root: &str) -> Arc<AgentBlockSettings> {
851        Arc::new(AgentBlockSettings {
852            script: build_inline_agent_invoker(&[]),
853            project_root: PathBuf::from(project_root),
854            mcp_rpc_timeout: Duration::from_secs(30),
855            profile_context: None,
856            task_metadata: None,
857        })
858    }
859
860    fn ctx_with_runtime(pairs: &[(&str, &str)]) -> Ctx {
861        let mut ctx = Ctx::new(StepId::parse("ST-project-root").unwrap(), 1, "writer");
862        for (k, v) in pairs {
863            ctx.meta
864                .runtime
865                .insert((*k).to_string(), serde_json::json!(v));
866        }
867        ctx
868    }
869
870    #[test]
871    fn resolve_settings_falls_back_to_spec_project_root_when_ctx_meta_runtime_absent() {
872        let spawner = AgentBlockCtxAwareSpawner {
873            agent_name: "writer".into(),
874            base_settings: test_settings("/spec-root"),
875        };
876        let ctx = ctx_with_runtime(&[]);
877        let resolved = spawner.resolve_settings(&ctx);
878        assert_eq!(resolved.project_root, PathBuf::from("/spec-root"));
879    }
880
881    #[test]
882    fn resolve_settings_prefers_ctx_meta_runtime_project_root_over_spec() {
883        let spawner = AgentBlockCtxAwareSpawner {
884            agent_name: "writer".into(),
885            base_settings: test_settings("/spec-root"),
886        };
887        let ctx = ctx_with_runtime(&[(TASK_PROJECT_ROOT_KEY, "/ctx-root")]);
888        let resolved = spawner.resolve_settings(&ctx);
889        assert_eq!(resolved.project_root, PathBuf::from("/ctx-root"));
890    }
891
892    #[test]
893    fn resolve_settings_prefers_ctx_meta_runtime_work_dir_over_project_root() {
894        let spawner = AgentBlockCtxAwareSpawner {
895            agent_name: "writer".into(),
896            base_settings: test_settings("/spec-root"),
897        };
898        let ctx = ctx_with_runtime(&[
899            (TASK_PROJECT_ROOT_KEY, "/ctx-root"),
900            (TASK_WORK_DIR_KEY, "/ctx-work"),
901        ]);
902        let resolved = spawner.resolve_settings(&ctx);
903        assert_eq!(resolved.project_root, PathBuf::from("/ctx-work"));
904    }
905
906    /// GH #20: `task_metadata` on the materialized `AgentContextView`
907    /// (the pull path this axis previously lacked entirely) is threaded
908    /// into `AgentBlockSettings.task_metadata`, overriding the
909    /// compile-time `None` baseline.
910    #[test]
911    fn resolve_settings_picks_up_task_metadata_from_ctx() {
912        let spawner = AgentBlockCtxAwareSpawner {
913            agent_name: "writer".into(),
914            base_settings: test_settings("/spec-root"),
915        };
916        let mut ctx = Ctx::new(StepId::parse("ST-project-root").unwrap(), 1, "writer");
917        ctx.meta.runtime.insert(
918            TASK_METADATA_KEY.to_string(),
919            serde_json::json!({"issue": 20}),
920        );
921        let resolved = spawner.resolve_settings(&ctx);
922        assert_eq!(
923            resolved.task_metadata,
924            Some(serde_json::json!({"issue": 20}))
925        );
926        // No project_root / work_dir override present — the compile-time
927        // project_root baseline is left untouched.
928        assert_eq!(resolved.project_root, PathBuf::from("/spec-root"));
929    }
930
931    /// Absent `task_metadata` in `ctx.meta.runtime` stays `None` — same
932    /// "insert nothing when absent" contract every other field follows.
933    #[test]
934    fn resolve_settings_task_metadata_stays_none_when_absent() {
935        let spawner = AgentBlockCtxAwareSpawner {
936            agent_name: "writer".into(),
937            base_settings: test_settings("/spec-root"),
938        };
939        let ctx = ctx_with_runtime(&[]);
940        let resolved = spawner.resolve_settings(&ctx);
941        assert!(resolved.task_metadata.is_none());
942    }
943
944    /// End-to-end: a real `Ctx.meta.runtime["project_root"]` override
945    /// reaches the AgentBlock spawn path through
946    /// `AgentBlockCtxAwareSpawner::spawn` — not just the pure
947    /// `resolve_settings` helper above. `spawn()` on an agent name that
948    /// is not `ctx.agent` fails fast with `SpawnError::NotRegistered`
949    /// (mirrors the pre-issue-#17 `InProcSpawner` registry-miss
950    /// behavior), which is enough to prove the ctx-aware wrapper reached
951    /// the inner `InProcSpawner::spawn` dispatch — settings resolution
952    /// itself is covered by the `resolve_settings` tests above.
953    #[tokio::test]
954    async fn spawn_delegates_to_inner_spawner_and_fails_fast_on_agent_mismatch() {
955        use crate::core::config::EngineCfg;
956        use crate::types::Role;
957
958        let spawner = AgentBlockCtxAwareSpawner {
959            agent_name: "writer".into(),
960            base_settings: test_settings("/spec-root"),
961        };
962        let ctx = ctx_with_runtime(&[(TASK_PROJECT_ROOT_KEY, "/ctx-root")]);
963        let mut mismatched_ctx = ctx.clone();
964        mismatched_ctx.agent = "not-writer".into();
965
966        let engine = Engine::new(EngineCfg::default());
967        let token = engine
968            .attach("ut-op", Role::Operator, Duration::from_secs(30))
969            .await
970            .expect("attach");
971        let task_id = StepId::parse("ST-project-root").unwrap();
972        let result = spawner
973            .spawn(&engine, &mismatched_ctx, task_id, 1, token)
974            .await;
975        // `Box<dyn Worker>` (the `Ok` payload) does not implement `Debug`,
976        // so a plain `match` is used instead of `expect_err`/`unwrap_err`.
977        let err = match result {
978            Err(e) => e,
979            Ok(_) => panic!("agent name mismatch must fail fast"),
980        };
981        assert!(
982            matches!(&err, SpawnError::NotRegistered(name) if name == "not-writer"),
983            "expected NotRegistered(\"not-writer\"), got: {err:?}"
984        );
985    }
986}