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