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`)
15//!
16//! | Mode | Trigger | Chunk |
17//! |---|---|---|
18//! | **PromptBasedAgent** (default) | `spec.script_path` absent | `ScriptSource::Inline` — the invoker [`build_inline_agent_invoker`] generates, which calls the SDK's `agent` StdPkg module with the resolved `mcp_servers` embedded. NOT the SDK's own `DefaultAgent`: that one passes no tools, so a frontmatter `tools:` line could never reach the model. |
19//! | **ScriptBasedAgent** | `spec.script_path = "<path>"` | `ScriptSource::Path(...)` — a caller-provided Lua script, handed to the SDK verbatim. |
20//!
21//! Neither mode requires the script to agree on an event-kind string: the
22//! `host_handler` sink takes any kind as the terminal result (see
23//! "Per-task input and result" below for the one reserved exception).
24//!
25//! `profile.system_prompt` (the agent.md body) is injected into the
26//! `_CONTEXT` Lua global through `BlockConfig.context`, and applies to
27//! both modes.
28//!
29//! ## Spec shape (`AgentDef.spec`)
30//!
31//! This is the settled `spec` contract for `AgentKind::AgentBlock` (GH
32//! #86) — every key is optional, and every key the factory reads is
33//! listed here:
34//!
35//! ```jsonc
36//! {
37//!   "project_root": "<path>",          // optional, default = std::env::current_dir()
38//!   "script_path": "<path>",           // optional; absent => PromptBasedAgent mode
39//!   "mcp_rpc_timeout_ms": 30000,       // optional, default = 30s
40//!   "mcp_servers": [                   // optional; the pool the tool grant selects from
41//!     { "name": "outline", "command": "outline-mcp", "args": [] }
42//!   ]
43//! }
44//! ```
45//!
46//! ## Tool grant (GH #86)
47//!
48//! The factory reads the effective tool set off `profile.tools` — which
49//! is **already** the resolved `Runner::AgentBlockInProcess.tools`
50//! whenever the agent declares that Runner, because
51//! `compiler::project_bound_agent_for_legacy_factories` overwrites
52//! `profile.tools` from the immutable `BoundAgent` snapshot (including
53//! with an empty list, so a Blueprint can revoke an agent.md's inherited
54//! `tools:` line). No Runner declared → the agent.md line stands. There
55//! is deliberately no build hint for this axis: re-deriving the Runner
56//! here would bypass the pinned snapshot and let a `Blueprint.runners`
57//! edit change an in-flight Run's grant on resume.
58//!
59//! Enforcement is per mode:
60//!
61//! | Mode | Enforcement |
62//! |---|---|
63//! | PromptBasedAgent | Enforced at **server** granularity. Only the `spec.mcp_servers` entries named by an `mcp__<server>__<tool>` entry of the effective set are embedded into the invoker ([`resolve_needed_mcp_servers`]), so the LLM cannot reach an unlisted server — but it CAN reach every tool of a listed one (the SDK exposes a connected server's full tool list). Grant per server, not per tool. |
64//! | ScriptBasedAgent | Not enforceable — the script drives its own `mcp.connect`. Declared `mcp__` entries are therefore **rejected at compile time** rather than silently ignored; drop them and let the script own its connections. |
65//!
66//! Non-`mcp__`-prefixed names (`Read` / `Write` / `WebSearch`) do not
67//! select an MCP server and are inert in both modes (see
68//! [`mcp_tools_of`]) — the `opts.extra_tools` carry noted on
69//! [`resolve_needed_mcp_servers`].
70//!
71//! ## Per-task input and result (GH #86)
72//!
73//! Task context reaches this backend through **one seam**:
74//! [`WorkerInvocation::context`], the in-process twin of
75//! `WorkerPayload.context`, filled once by `InProcSpawner::spawn` from the
76//! materialized [`AgentContextView`]. Nothing here peeks at `Ctx`
77//! directly, and no `SpawnerAdapter` wrapper re-resolves it — every
78//! Lua-visible surface below is derived from that one value:
79//!
80//! | Lua surface | Source |
81//! |---|---|
82//! | `_PROMPT` | The step's evaluated `in`, via `inv.prompt` → `BlockConfig.prompt`. A **String** — a structured `in` arrives JSON-stringified, so a script that wants a table calls `std.json.decode(_PROMPT)`. |
83//! | `_CONTEXT` | `profile.system_prompt`, via `BlockConfig.context`. |
84//! | [`TASK_METADATA_GLOBAL`] (`_TASK_METADATA`) | `view.task_metadata` (the launch's `init_ctx.task_metadata` bag), set through the SDK's `extra_globals` — converted natively, so nesting survives and the chunk text is never rewritten. |
85//! | [`AGENT_CTX_GLOBAL`] (`_AGENT_CTX`) | `view.extra` — the Blueprint-declared agent context (`default_agent_ctx` / `AgentMeta.ctx`, GH #21) after `ContextPolicy` filtering. |
86//!
87//! Both come from [`context_globals`], the single place this mapping is
88//! decided; the Lua in-process worker renders the same two globals from
89//! it, so a gate is portable between the two backends. `view.steps`
90//! (prior-step OUTPUT pointers) is deliberately NOT rendered — see the
91//! carrier note in [`crate::core::agent_context`].
92//!
93//! No server-process env is involved in any of them. The per-task working
94//! directory is not a Lua global — it becomes the SDK's `project_root`
95//! (see the next section), which surfaces to a script as
96//! `std.env.project_root()` and as the default cwd of `sh.exec` and of
97//! MCP servers spawned by `mcp.connect`. It does NOT `chdir` the host
98//! process, so a bare `io.open("rel/path")` still resolves against the
99//! server's own cwd.
100//!
101//! A script returns its result by calling `bus.emit(<kind>, payload)` —
102//! **not** by returning a value from the chunk. Two destinations:
103//!
104//! | emit kind | Effect |
105//! |---|---|
106//! | [`ARTIFACT_EVENT_KIND`] (`artifact`) | Stages a named part through [`WorkerInvocation::sink`] (`{name = ..., content = ...}`) and leaves the invocation running. Any number of these. |
107//! | anything else | The terminal result, **first emit wins**. [`WorkerResultCaptor`] normalises the payload into [`WorkerResult`]`.value` (`payload.content` → `payload.response` → the whole payload). |
108//!
109//! Both verdict channels are therefore reachable:
110//! `VerdictChannel::Body` compares the terminal value, and
111//! `VerdictChannel::Part` compares a staged `"verdict"` part — stage it
112//! with `bus.emit("artifact", {name = "verdict", content = "PASS"})`
113//! before the terminal emit, and the plain body stays free for the
114//! report.
115//!
116//! ## `project_root` resolution (issue #17, GH #20)
117//!
118//! `spec.project_root` (above) is only the **compile-time fallback**
119//! tier — resolved once in [`AgentBlockInProcessSpawnerFactory::build`],
120//! before any `Ctx` exists. Per invocation, [`resolve_project_root`]
121//! applies the task-context tier off [`WorkerInvocation::context`] (GH
122//! #20 Contract C — see [`crate::core::agent_context`] for the full
123//! narrative) with this priority (highest first):
124//!
125//! 1. `view.work_dir` — Task-level, set by `TaskInputMiddleware` from
126//!    the launch's `init_ctx.work_dir`.
127//! 2. `view.project_root` — same middleware, `init_ctx.project_root`.
128//! 3. `spec.project_root` / `std::env::current_dir()` (the compile-time
129//!    fallback baked into [`AgentBlockSettings`] above).
130//!
131//! This lets a single Blueprint's `AgentDef.spec.project_root` (fixed at
132//! compile time) be overridden per task launch, so the same Blueprint
133//! can run against different caller-supplied project roots without a
134//! `spec` edit.
135//!
136//! ## SDK paths introduced from v0.22.0 through v0.27.0
137//!
138//! | Version | Feature | Use case |
139//! |---|---|---|
140//! | v0.22.0 | `bus.emit(kind, payload, id?)` Lua bridge | script → host event push |
141//! | v0.23.0 | `BlockConfig.host_handlers` | Pre-install a Rust handler on the EventBus |
142//! | v0.24.0 | `BlockConfig.auto_serve_bus` | SDK embed drives the dispatcher in the background |
143//! | v0.25.0 | `BlockConfig.shutdown_token` + `BlockError::Cancelled` + `Send` on `run()` | `tokio::spawn` and external cancel |
144//! | v0.26.0 | `ScriptSource` / `PromptSource` / `SecretKeySource` enums plus the embedded `DefaultAgent` invoker (breaking) | Script becomes optional at the SDK level |
145//! | v0.27.0 | Embed the `compile_loop` StdPkg into core | `require("compile_loop")` hits directly |
146
147use crate::core::agent_context::AgentContextView;
148use crate::worker::adapter::{InProcSpawner, WorkerError, WorkerInvocation, WorkerResult};
149use agent_block_core::bus::dispatcher::Handler;
150use agent_block_core::host::{PromptSource, ScriptSource};
151use agent_block_core::{run, BlockConfig};
152use agent_block_types::error::BlockError;
153use async_trait::async_trait;
154use serde_json::Value;
155use std::collections::HashMap;
156use std::path::{Path, PathBuf};
157use std::sync::{Arc, Mutex};
158use std::time::Duration;
159use tokio::sync::oneshot;
160
161/// Host-side handler that fires when the Lua script (or the
162/// DefaultAgent invoker) calls `bus.emit(<kind>, payload)`. It folds
163/// the payload into a [`WorkerResult`] and forwards it on the
164/// [`oneshot::Sender`].
165///
166/// This is **an AgentBlock-internal helper**. Different SDK paths use
167/// different event names and payload shapes — the DefaultAgent
168/// invoker's `agent_result` event carries the entire `agent.run`
169/// return value (`{content, messages, num_turns, ok, usage}`), while a
170/// caller script's `worker_result` event carries `{ok, response}`. The
171/// captor keeps those quirks contained and **normalises them**, so
172/// callers (flow.ir, the engine, higher-level Workers) always see the
173/// same single form: "the raw LLM response is `WorkerResult.value`".
174///
175/// Value extraction priority (the normalisation policy that hides the
176/// SDK quirks):
177///
178/// 1. `payload.content` — from the DefaultAgent invoker / `agent.run`
179///    return value; carried as a string.
180/// 2. `payload.response` — the caller script's `worker_result`
181///    convention; free-form.
182/// 3. Fallback: the whole payload — for custom shapes that carry
183///    neither of the above.
184///
185/// `ok` extraction: `payload.ok` if present, otherwise `true` — the
186/// DefaultAgent invoker includes `ok`, so this recovers it.
187///
188/// This is the core of the observation #2 fix. The previous
189/// implementation did not consult (1); it only fell back
190/// `(2) → (3)`. On the DefaultAgent path that pushed the whole
191/// `agent_result` object into `WorkerResult.value`, which then rode
192/// through the chain and hit the next step's prompt via
193/// JSON-stringification — burning 50-60% of the tokens on
194/// boilerplate. Pulling out (1) first normalises the chain to a single
195/// LLM raw-text carry and brings the Worker pattern up to the token
196/// efficiency of the Phase 3 WS Operator path.
197///
198/// # Named parts (GH #86)
199///
200/// One event kind is reserved: [`ARTIFACT_EVENT_KIND`]. An emit under that
201/// kind is staged as an [`OutputEvent::Artifact`] through
202/// [`WorkerInvocation::sink`] and does **not** complete the invocation, so
203/// a script may stage any number of named parts and then finish with its
204/// terminal emit as usual. This is what makes `VerdictChannel::Part`
205/// reachable from this backend — the engine's completion-time contract
206/// check looks for a staged `"verdict"` artifact.
207///
208/// Reserving a kind is a deliberate step back from "kind-agnostic": with
209/// two destinations there is now something to route, so the script side
210/// has one string to coordinate. Every other kind keeps the old
211/// first-emit-wins result behavior.
212struct WorkerResultCaptor {
213    tx: Mutex<Option<oneshot::Sender<WorkerResult>>>,
214    /// Intake for staged named parts; `None` when the caller path did not
215    /// wire one (an `artifact` emit then degrades to a `tracing::warn!`
216    /// rather than silently vanishing).
217    sink: Option<Arc<dyn crate::worker::output::OutputSink>>,
218    /// The agent's Blueprint-declared model
219    /// ([`AgentBlockSettings::declared_model`]) — the fallback for
220    /// `stats.model` when the payload does not report one at runtime.
221    declared_model: Option<String>,
222}
223
224impl WorkerResultCaptor {
225    /// SDK-quirks normalisation: extract `(value, ok)` from a
226    /// `bus.emit` payload. `pub(crate)` so both callers and unit tests
227    /// can reach it.
228    fn extract(payload: &Value) -> (Value, bool) {
229        let ok = payload.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
230        let value = payload
231            .get("content")
232            .cloned()
233            .or_else(|| payload.get("response").cloned())
234            .unwrap_or_else(|| payload.clone());
235        (value, ok)
236    }
237
238    /// Stats-sidecar extraction (per-step run stats): the DefaultAgent
239    /// invoker's `agent_result` payload carries the full `agent.run`
240    /// return, whose `usage` (`{input_tokens, output_tokens,
241    /// total_tokens}`, all turns summed) and `num_turns` used to be
242    /// DROPPED here — the exact gap this recovers. `None` when the
243    /// payload carries none of them (caller-script `worker_result`
244    /// shapes). The raw `usage` object also rides as `adapter_data` so
245    /// provider-specific detail (cache tokens etc.) survives.
246    ///
247    /// A payload-level `"model"` string is the **runtime-observed** model
248    /// and is adopted verbatim; it outranks the Blueprint-declared
249    /// fallback applied in [`Handler::call`], because what actually
250    /// served the attempt beats what was asked for.
251    fn extract_stats(payload: &Value) -> Option<crate::store::trace::WorkerStats> {
252        let usage_raw = payload.get("usage");
253        let usage = usage_raw.and_then(|u| {
254            // Partial reports count: a block that surfaces only a total
255            // (or only the splits) still lands a usage record — see
256            // `TokenUsage::from_parts` for the normalization rule.
257            crate::store::trace::TokenUsage::from_parts(
258                u.get("input_tokens").and_then(|v| v.as_u64()),
259                u.get("output_tokens").and_then(|v| v.as_u64()),
260                u.get("total_tokens").and_then(|v| v.as_u64()),
261            )
262        });
263        let num_turns = payload
264            .get("num_turns")
265            .and_then(|v| v.as_u64())
266            .map(|n| n as u32);
267        let model = payload
268            .get("model")
269            .and_then(|v| v.as_str())
270            .map(str::to_string);
271        if usage.is_none() && num_turns.is_none() && model.is_none() {
272            return None;
273        }
274        Some(crate::store::trace::WorkerStats {
275            worker_kind: Some("agent_block".to_string()),
276            model,
277            usage,
278            num_turns,
279            adapter_data: usage_raw.cloned(),
280        })
281    }
282
283    /// GH #86: stage one named part from an [`ARTIFACT_EVENT_KIND`] emit.
284    ///
285    /// `payload.name` (string) is required — an emit without it cannot
286    /// address a part, so it is reported to the script as an error rather
287    /// than dropped. `payload.content` is the body; absent means the part
288    /// is staged empty (`Value::Null`), which is still a distinct,
289    /// addressable staging event.
290    async fn stage_artifact(&self, payload: &Value) -> Result<(), BlockError> {
291        let name = payload
292            .get("name")
293            .and_then(|v| v.as_str())
294            .ok_or_else(|| {
295                BlockError::Runtime(format!(
296                    "bus.emit(\"{ARTIFACT_EVENT_KIND}\", ...) requires a string `name` field \
297                     naming the part (got: {payload})"
298                ))
299            })?
300            .to_string();
301        let Some(sink) = self.sink.as_ref() else {
302            tracing::warn!(
303                artifact = %name,
304                "agent-block staged an artifact but no OutputSink is wired for this \
305                 invocation; the part is dropped"
306            );
307            return Ok(());
308        };
309        let content = payload.get("content").cloned().unwrap_or(Value::Null);
310        sink.emit(crate::worker::output::OutputEvent::Artifact {
311            name: name.clone(),
312            content: crate::worker::output::ContentRef::Inline { value: content },
313        })
314        .await
315        .map_err(|e| BlockError::Runtime(format!("staging artifact '{name}': {e}")))?;
316        Ok(())
317    }
318}
319
320#[async_trait]
321impl Handler for WorkerResultCaptor {
322    async fn call(
323        &self,
324        kind: String,
325        _id: String,
326        payload: Value,
327        _meta: Value,
328    ) -> Result<Value, BlockError> {
329        // GH #86: the one reserved kind routes to the named-part intake and
330        // leaves the invocation running; everything else is the terminal
331        // result (first emit wins).
332        if kind == ARTIFACT_EVENT_KIND {
333            self.stage_artifact(&payload).await?;
334            return Ok(Value::Null);
335        }
336        let (value, ok) = Self::extract(&payload);
337        let stats = Self::extract_stats(&payload);
338        // Even when the SDK payload carries no usage (script-side
339        // `worker_result` shapes), the boundary still knows its own
340        // kind — surface it so `StepEntry.worker_kind` is never empty.
341        let mut wr = WorkerResult { value, ok, stats }.ensure_worker_kind("agent_block");
342        // `agent.run` returns no model, so without this the sidecar's
343        // `model` was always empty on this backend. Fall back to the
344        // Blueprint-declared `profile.model`, mirroring the subprocess
345        // backend's baked `{model}`. Precedence: runtime-observed
346        // (`extract_stats`) > declared > none — `get_or_insert_with`
347        // encodes exactly that (it is a no-op once a value is present).
348        if let (Some(declared), Some(stats)) = (self.declared_model.as_deref(), wr.stats.as_mut()) {
349            stats.model.get_or_insert_with(|| declared.to_string());
350        }
351        if let Ok(mut guard) = self.tx.lock() {
352            if let Some(tx) = guard.take() {
353                let _ = tx.send(wr);
354            }
355        }
356        Ok(Value::Null)
357    }
358}
359
360/// The Lua global carrying the launch's `init_ctx.task_metadata` bag into
361/// a script, set through the SDK's `extra_globals` (agent-block-core
362/// v0.30+).
363///
364/// Underscore-prefixed to sit alongside the SDK's own globals.
365/// `_PROMPT` / `_CONTEXT` / `_SCRIPT_NAME` are reserved by the SDK and
366/// must not be used here; this name is ours.
367pub const TASK_METADATA_GLOBAL: &str = "_TASK_METADATA";
368
369/// The Lua global carrying the Blueprint-declared agent context — the
370/// `AgentContextView.extra` bag, which `AgentContextMiddleware` fills from
371/// `Blueprint.default_agent_ctx` / `AgentMeta.ctx` (GH #21) after applying
372/// `ContextPolicy`.
373///
374/// The WS Operator lane has always received these as `{key}: {value}`
375/// lines of the Spawn directive header
376/// (`AgentContextView::to_directive_header`); this is the in-process
377/// equivalent. Delivered as ONE table rather than one global per key,
378/// because the keys are Blueprint-author-chosen and must not be able to
379/// shadow `_PROMPT` / `_CONTEXT` / `_SCRIPT_NAME` or each other's
380/// namespace.
381pub const AGENT_CTX_GLOBAL: &str = "_AGENT_CTX";
382
383/// The Lua globals this backend derives from the materialized context
384/// view — the single place the mapping "view field → Lua global" is
385/// decided, so [`crate::blueprint::compiler`]'s Lua worker can render the
386/// same surface and keep a gate portable between the two in-process
387/// backends.
388///
389/// Absent / empty inputs contribute no entry at all (rather than an empty
390/// table), so a script sees `nil` and can branch on presence — the same
391/// "insert nothing when absent" contract the rest of this axis follows.
392pub fn context_globals(view: Option<&AgentContextView>) -> HashMap<String, Value> {
393    let mut globals = HashMap::new();
394    let Some(view) = view else {
395        return globals;
396    };
397    if let Some(meta) = view.task_metadata.clone() {
398        globals.insert(TASK_METADATA_GLOBAL.to_string(), meta);
399    }
400    if !view.extra.is_empty() {
401        globals.insert(
402            AGENT_CTX_GLOBAL.to_string(),
403            Value::Object(view.extra.clone()),
404        );
405    }
406    globals
407}
408
409/// The one `bus.emit` kind this backend reserves: an emit under it stages
410/// a named part instead of completing the invocation (GH #86).
411///
412/// Payload shape: `{ name = "<part>", content = <any> }`. `name` is
413/// required. Reaching `VerdictChannel::Part` from a script means staging
414/// `{ name = "verdict", content = "PASS" }` before the terminal emit.
415pub const ARTIFACT_EVENT_KIND: &str = "artifact";
416
417/// Settings baked per `AgentDef` — the static portion of one
418/// invocation. Everything task-dependent (`project_root` /
419/// `task_metadata`) is resolved per invocation off
420/// [`WorkerInvocation::context`] instead, so this struct is built once at
421/// compile time and shared by every dispatch of the agent.
422///
423/// v0.28.0 adopted `BlockConfig.host_handler` (a kind-agnostic
424/// single sink backed by `EventBus::on_any`); the older
425/// `result_event_kind: String` field (which required the caller /
426/// script to coordinate a kind string) is gone. One captor per
427/// invocation is enough, so a single sink is enough.
428#[derive(Clone)]
429struct AgentBlockSettings {
430    /// The chunk to run — see [`ScriptPlan`].
431    script: ScriptSource,
432    /// Compile-time fallback cwd: `spec.project_root`, else
433    /// `env::current_dir()`. Outranked per invocation by the context
434    /// view's `work_dir` / `project_root`.
435    spec_project_root: PathBuf,
436    mcp_rpc_timeout: Duration,
437    /// Agent persona — the `system_prompt` composed from the agent.md
438    /// body and frontmatter. `None` maps to `BlockConfig.context = None`
439    /// for backwards compatibility with the old path.
440    profile_context: Option<String>,
441    /// The agent.md frontmatter `model:` line (`profile.model`), baked at
442    /// compile time. Observational only — this backend does not select a
443    /// model with it (the SDK owns that); it is the declared fallback for
444    /// the per-step `stats.model` sidecar, so a step of an agent whose BP
445    /// names a model is attributable even though `agent.run` reports
446    /// none. Sibling of the subprocess backend's baked `{model}`.
447    declared_model: Option<String>,
448}
449
450/// One invocation's worth of an `agent-block-core` SDK call — the
451/// `WorkerFn` body.
452///
453/// Registers the result captor through the v0.28.0 `host_handler`
454/// (single, kind-agnostic fallback). The plural `host_handlers`
455/// (string-keyed routing) is not needed — one captor per invocation is
456/// enough, and there is no script-side event-kind string to coordinate.
457async fn run_agent_block_worker(
458    settings: Arc<AgentBlockSettings>,
459    inv: WorkerInvocation,
460) -> Result<WorkerResult, WorkerError> {
461    let (tx, rx) = oneshot::channel();
462    let captor: Arc<dyn Handler> = Arc::new(WorkerResultCaptor {
463        tx: Mutex::new(Some(tx)),
464        sink: inv.sink.clone(),
465        declared_model: settings.declared_model.clone(),
466    });
467
468    // GH #86: the task-context tier, read off the ONE in-process seam
469    // (`WorkerInvocation.context`, filled by `InProcSpawner::spawn` from
470    // the materialized `AgentContextView`) instead of a hand-rolled `Ctx`
471    // peek in a spawner wrapper.
472    let project_root = resolve_project_root(inv.context.as_ref(), &settings.spec_project_root);
473
474    // Bridge the shutdown token: forward `WorkerInvocation.cancel_token`
475    // into the SDK's `shutdown_token` if one is set; otherwise use a
476    // fresh token (no external cancel).
477    let shutdown_token = inv.cancel_token.clone().unwrap_or_default();
478
479    let mut builder = BlockConfig::builder(settings.script.clone(), project_root)
480        .mcp_rpc_timeout(settings.mcp_rpc_timeout)
481        .prompt(PromptSource::Inline(inv.prompt))
482        .host_handler(captor)
483        .auto_serve_bus(true)
484        .shutdown_token(shutdown_token.clone());
485    if let Some(system) = settings.profile_context.clone() {
486        builder = builder.context(PromptSource::Inline(system));
487    }
488    // The task-context globals. `extra_globals` (SDK v0.30) sets each entry
489    // on both Isles before the chunk runs, converting the JSON natively —
490    // so nothing is spliced into the script text: no line-number shift, no
491    // `script_dir` / `package.path` disturbance, and no string-escaping
492    // trust boundary around caller-supplied values.
493    let globals = context_globals(inv.context.as_ref());
494    if !globals.is_empty() {
495        builder = builder.extra_globals(globals);
496    }
497    let config = builder.build();
498
499    let run_handle = tokio::spawn(run(config));
500    let run_result = run_handle
501        .await
502        .map_err(|e| WorkerError::Failed(format!("agent-block task join: {e}")))?;
503    run_result.map_err(|e| WorkerError::Failed(format!("agent-block run failed: {e}")))?;
504
505    rx.await.map_err(|_| {
506        WorkerError::Failed("agent-block script finished without emitting result via bus".into())
507    })
508}
509
510// ─── tools / mcp_servers resolution ───────────────────────────────────────
511
512/// Cross-reference the agent's declared tool set (see
513/// [`resolve_effective_tools`] for which tier that comes from) with
514/// `spec.mcp_servers` (the `"server name" → command + args` mapping
515/// provided by the `AgentDef` literal cascade) and resolve the
516/// `mcp_servers` config actually exposed to the LLM for this invocation.
517///
518/// Algorithm:
519///
520/// 1. Extract `mcp__<server>__<tool>` patterns from `declared_tools`;
521///    collect the `<server>` names.
522/// 2. Filter `spec.mcp_servers` to just the entries whose name is in
523///    that set.
524///
525/// This is the response to observation #3 — do not hand the LLM
526/// `mcp_servers` it does not need (only the servers the declaration
527/// explicitly asks for), and equally do not expose servers the
528/// declaration does not know about even if the spec carries them
529/// (caller intent wins).
530///
531/// CC built-in tools (non-`mcp__`-prefixed names like `Read` / `Write`
532/// / `WebSearch`) are out of scope here; handling those lives in a
533/// different layer — a carry that would come through a future
534/// `opts.extra_tools` Rust implementation.
535pub fn resolve_needed_mcp_servers(
536    declared_tools: &[String],
537    spec_mcp_servers: &[Value],
538) -> Vec<Value> {
539    use std::collections::HashSet;
540    // Step 1: server names from `mcp__<server>__<tool>` patterns in the
541    // declared tool set.
542    let needed: HashSet<&str> = declared_tools
543        .iter()
544        .filter_map(|t| {
545            let rest = t.strip_prefix("mcp__")?;
546            // Split `<server>__<tool>` at the first `__`.
547            let idx = rest.find("__")?;
548            Some(&rest[..idx])
549        })
550        .collect();
551
552    // Step 2: filter `spec.mcp_servers` down to entries whose name is
553    // in `needed`.
554    spec_mcp_servers
555        .iter()
556        .filter(|cfg| {
557            cfg.get("name")
558                .and_then(|n| n.as_str())
559                .map(|name| needed.contains(name))
560                .unwrap_or(false)
561        })
562        .cloned()
563        .collect()
564}
565
566/// GH #86 — the subset of an effective tool set that names an MCP server,
567/// i.e. the only entries this backend's grant model can act on.
568///
569/// Everything else (`Read` / `Write` / `WebSearch` …) selects no server and
570/// is inert here, so it is neither embedded nor treated as a grant that
571/// must be honored — see [`resolve_needed_mcp_servers`]'s `opts.extra_tools`
572/// carry. Used by the ScriptBasedAgent guard in
573/// [`AgentBlockInProcessSpawnerFactory::build`], which must not fail an
574/// agent whose declared tools are all inert.
575fn mcp_tools_of(tools: &[String]) -> Vec<&str> {
576    tools
577        .iter()
578        .filter(|t| t.starts_with("mcp__"))
579        .map(String::as_str)
580        .collect()
581}
582
583/// Build the inline Lua script used on the PromptBasedAgent path (when
584/// `spec.script_path` is absent). Instead of the SDK's embedded
585/// `DEFAULT_AGENT_INVOKER` (which passes no tools), this embeds
586/// `mcp_servers` as a Lua literal table and hands it to `agent.run`.
587///
588/// This is the core of the observation #3 fix. The old DefaultAgent
589/// path had no way to deliver a frontmatter `tools:` line to the SDK.
590/// This inline path bakes the `profile.tools` → `mcp_servers` config
591/// into the Lua source, so the LLM can actually make tool calls.
592///
593/// The JSON-stringify + `std.json.decode` route was ruled out because
594/// the SDK environment cannot `require` the `std` module (no
595/// `package.preload['std']` field), so we take the JSON → Lua-literal
596/// conversion on the Rust side and embed the result directly. The
597/// event name is `agent_result` — the same convention the SDK's
598/// internal `DEFAULT_AGENT_INVOKER` uses.
599pub fn build_inline_agent_invoker(mcp_servers: &[Value]) -> ScriptSource {
600    let mcp_lua = json_array_to_lua_literal(mcp_servers);
601    let source = format!(
602        r##"local agent = require("agent")
603local mcp_servers = {mcp_lua}
604local r = agent.run({{
605    prompt = _PROMPT,
606    system = _CONTEXT,
607    mcp_servers = mcp_servers,
608}})
609bus.emit("agent_result", r)
610"##
611    );
612    ScriptSource::Inline {
613        source,
614        name: "mlua_swarm_engine_default_agent_invoker.lua".into(),
615    }
616}
617
618/// Convert a JSON `Value` into a Lua literal expression, for embedding
619/// into the inline script. Lua string escaping is delegated to Rust's
620/// `{:?}` `Debug` output — Lua syntax is compatible with the escapes
621/// it produces (`"`, `\\`, `\n`, `\r`, `\t`, and so on). Edge cases
622/// like `\0` or unusual Unicode escapes are outside the scope of this
623/// use.
624fn json_to_lua_literal(v: &Value) -> String {
625    match v {
626        Value::Null => "nil".to_string(),
627        Value::Bool(b) => b.to_string(),
628        Value::Number(n) => n.to_string(),
629        Value::String(s) => format!("{s:?}"),
630        Value::Array(arr) => {
631            let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
632            format!("{{{}}}", items.join(", "))
633        }
634        Value::Object(map) => {
635            let items: Vec<String> = map
636                .iter()
637                .map(|(k, v)| format!("[{k:?}]={}", json_to_lua_literal(v)))
638                .collect();
639            format!("{{{}}}", items.join(", "))
640        }
641    }
642}
643
644/// Convert a `Vec<Value>` into a Lua literal sequence. An empty array
645/// becomes `{}` — a Lua empty table.
646fn json_array_to_lua_literal(arr: &[Value]) -> String {
647    if arr.is_empty() {
648        return "{}".to_string();
649    }
650    let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
651    format!("{{{}}}", items.join(", "))
652}
653
654// ─── SpawnerFactory ───────────────────────────────────────────────────────
655
656/// The compile-time (`spec` / `env::current_dir()`) fallback tier of the
657/// `project_root` priority chain (issue #17) — the tail two links of
658/// **`ctx.meta.runtime` `work_dir` > `ctx.meta.runtime` `project_root` >
659/// `spec.project_root` > `env::current_dir()`**. Extracted as a standalone
660/// pure fn so it is independently testable without needing a full `Ctx` /
661/// `SpawnerAdapter` round-trip.
662fn resolve_spec_project_root(spec: &Value) -> PathBuf {
663    match spec.get("project_root").and_then(|v| v.as_str()) {
664        Some(s) => PathBuf::from(s),
665        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
666    }
667}
668
669/// Apply the task-context tier on top of the compile-time fallback:
670/// **`view.work_dir` > `view.project_root` > `spec_fallback`**.
671///
672/// `work_dir` outranks `project_root` because it names the exact directory
673/// this specific worker should run from. A `None` view (no `Ctx` on the
674/// caller path) leaves the compile-time fallback in place.
675fn resolve_project_root(view: Option<&AgentContextView>, spec_fallback: &Path) -> PathBuf {
676    view.and_then(|v| v.work_dir.as_deref().or(v.project_root.as_deref()))
677        .map(PathBuf::from)
678        .unwrap_or_else(|| spec_fallback.to_path_buf())
679}
680
681/// The `SpawnerFactory` for AgentBlock. `KIND = AgentKind::AgentBlock`.
682///
683/// **State-less.** One factory per process; every `AgentDef` uses it
684/// as a shared builder. Per-agent specialisation stays **entirely
685/// inside `AgentDef.spec` + `AgentDef.profile`** — the old
686/// `default_script_path` / `default_project_root` fields are gone.
687///
688/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` — an
689/// AgentBlock worker on the InProcess adapter.
690pub struct AgentBlockInProcessSpawnerFactory;
691
692impl Default for AgentBlockInProcessSpawnerFactory {
693    fn default() -> Self {
694        Self
695    }
696}
697
698impl AgentBlockInProcessSpawnerFactory {
699    /// Stateless constructor — equivalent to `Default::default()`.
700    pub fn new() -> Self {
701        Self
702    }
703}
704
705impl crate::blueprint::compiler::SpawnerFactoryKind for AgentBlockInProcessSpawnerFactory {
706    const KIND: crate::blueprint::AgentKind = crate::blueprint::AgentKind::AgentBlock;
707    type Worker = AgentBlockWorker;
708}
709
710impl crate::blueprint::compiler::SpawnerFactory for AgentBlockInProcessSpawnerFactory {
711    fn build(
712        &self,
713        agent_def: &crate::blueprint::AgentDef,
714        _hint: Option<&Value>,
715    ) -> Result<
716        Arc<dyn crate::worker::adapter::SpawnerAdapter>,
717        crate::blueprint::compiler::CompileError,
718    > {
719        let agent_name = agent_def.name.clone();
720        let spec = &agent_def.spec;
721
722        // Resolve the actual mcp_servers config to pass to the real LLM by
723        // combining the effective tool set with spec.mcp_servers (the first
724        // axis of AgentDef literal cascade — a "server name → command +
725        // args" mapping). The result is JSON-embedded into the Lua source by
726        // build_inline_agent_invoker and flows into
727        // `agent.run({mcp_servers=...})`.
728        //
729        // `profile.tools` IS the effective set: when the agent declares a
730        // `Runner::AgentBlockInProcess`, the compiler has already overwritten
731        // `profile.tools` with that Runner's `tools` off the pinned
732        // `BoundAgent` snapshot (`project_bound_agent_for_legacy_factories`),
733        // including with an empty list. No Runner declared → the agent.md
734        // `tools:` line stands as-is. See the module doc's "Tool grant".
735        let effective_tools: Vec<String> = agent_def
736            .profile
737            .as_ref()
738            .map(|p| p.tools.clone())
739            .unwrap_or_default();
740        let spec_mcp_servers: Vec<Value> = spec
741            .get("mcp_servers")
742            .and_then(|v| v.as_array())
743            .cloned()
744            .unwrap_or_default();
745        let needed_mcp_servers = resolve_needed_mcp_servers(&effective_tools, &spec_mcp_servers);
746
747        // script: `spec.script_path` absent → PromptBasedAgent (the new Inline
748        //         path, embedding tools and calling agent.run); present →
749        //         ScriptBasedAgent (a caller-provided script path where tools
750        //         are the caller's responsibility). Event-kind string
751        //         dependency was retired — the `host_handler` single sink
752        //         captures every kind.
753        let script = match spec.get("script_path").and_then(|v| v.as_str()) {
754            Some(s) => {
755                // GH #86: a caller script drives its own `mcp.connect`, so the
756                // host has no choke point to enforce an MCP grant through —
757                // the Inline invoker's "embed exactly the declared servers"
758                // lever does not exist on this path. Declaring MCP tools here
759                // would be a promise the runtime cannot keep, so it is
760                // rejected at compile time instead of silently ignored.
761                //
762                // Only `mcp__`-prefixed entries trigger this: everything else
763                // selects no server and is inert in both modes, so an agent.md
764                // `tools: Read, WebSearch` line must not fail a script-mode
765                // agent that compiled before this guard existed.
766                let mcp_tools = mcp_tools_of(&effective_tools);
767                if !mcp_tools.is_empty() {
768                    return Err(crate::blueprint::compiler::CompileError::InvalidSpec {
769                        name: agent_name,
770                        msg: format!(
771                            "agent_block ScriptBasedAgent mode (spec.script_path = {s:?}) cannot \
772                             enforce an MCP tool grant: the script opens its own connections via \
773                             `mcp.connect`, so the declared tools ({}) would be unenforceable. \
774                             Either drop spec.script_path to use PromptBasedAgent mode (where the \
775                             declared servers ARE the only ones embedded into the invoker), or \
776                             drop the mcp__ entries and let the script own its connections.",
777                            mcp_tools.join(", ")
778                        ),
779                    });
780                }
781                ScriptSource::Path(PathBuf::from(s))
782            }
783            None => build_inline_agent_invoker(&needed_mcp_servers),
784        };
785
786        // issue #17: this is the compile-time fallback tier only —
787        // `spec.project_root`, then `env::current_dir()`. No `Ctx` exists
788        // yet at `build()` time, so the higher-priority task-context tier
789        // cannot be consulted here; `run_agent_block_worker` applies it per
790        // invocation off `WorkerInvocation.context` (see the module-level
791        // "`project_root` resolution" doc).
792        let spec_project_root = resolve_spec_project_root(spec);
793        let mcp_rpc_timeout = match spec.get("mcp_rpc_timeout_ms").and_then(|v| v.as_u64()) {
794            Some(ms) => Duration::from_millis(ms),
795            None => Duration::from_secs(30),
796        };
797        let profile_context = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
798        // Same source the subprocess backend bakes its `{model}`
799        // placeholder from; here it only ever reaches the per-step stats
800        // sidecar (see `AgentBlockSettings::declared_model`).
801        let declared_model = agent_def.profile.as_ref().and_then(|p| p.model.clone());
802
803        let settings = Arc::new(AgentBlockSettings {
804            script,
805            spec_project_root,
806            mcp_rpc_timeout,
807            profile_context,
808            declared_model,
809        });
810
811        // A plain `InProcSpawner` with this agent's single route. GH #86
812        // removed the `AgentBlockCtxAwareSpawner` wrapper that used to sit
813        // here purely to re-resolve `ctx.meta.runtime` at spawn time: the
814        // task-context tier now arrives on `WorkerInvocation.context`, the
815        // same seam every other in-process worker reads, so the worker fn
816        // resolves it itself and no bespoke adapter is needed.
817        let worker_fn: crate::worker::adapter::WorkerFn = Arc::new(move |inv| {
818            let settings = settings.clone();
819            Box::pin(run_agent_block_worker(settings, inv))
820        });
821        let mut sp: InProcSpawner<AgentBlockWorker> = InProcSpawner::<AgentBlockWorker>::typed();
822        sp.registry.insert(agent_name, worker_fn);
823        Ok(Arc::new(sp))
824    }
825}
826
827/// Concrete Worker type for the AgentBlock kind — the handle for an
828/// LLM call routed through the `agent-block-core` SDK. Embeds a
829/// `WorkerJoinHandler` to carry the async signal. The intent is to
830/// eventually keep the SDK-specific quirks — the `agent_result` event
831/// name, payload shape, shutdown-token bridging, agent_result.content
832/// normalisation — contained inside this struct. Today it lands as a
833/// thin shape holding only the async signal; Phase B adds the
834/// normalisation layer here and structurally eliminates the
835/// token-boilerplate waste observed in observation #2.
836pub struct AgentBlockWorker {
837    /// The completion-signal handle for this agent-block SDK call's
838    /// spawned task.
839    pub handler: crate::worker::WorkerJoinHandler,
840}
841
842impl From<crate::worker::WorkerJoinHandler> for AgentBlockWorker {
843    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
844        Self { handler }
845    }
846}
847
848#[async_trait]
849impl crate::worker::Worker for AgentBlockWorker {
850    fn id(&self) -> &crate::types::WorkerId {
851        &self.handler.worker_id
852    }
853    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
854        self.handler.cancel.clone()
855    }
856    async fn join(self: Box<Self>) -> Result<(), WorkerError> {
857        self.handler.await_completion().await
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::core::agent_context::{TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
865
866    #[test]
867    fn resolve_needed_mcp_servers_filters_by_tool_prefix() {
868        let tools = vec![
869            "mcp__semantic-scholar__search_papers".to_string(),
870            "mcp__semantic-scholar__get_paper".to_string(),
871            "Read".to_string(),
872            "mcp__outline__list_docs".to_string(),
873            "WebSearch".to_string(),
874        ];
875        let spec_servers = vec![
876            serde_json::json!({"name": "semantic-scholar", "command": "ss-mcp", "args": []}),
877            serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []}),
878            serde_json::json!({"name": "unused", "command": "nope", "args": []}),
879        ];
880        let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
881        assert_eq!(needed.len(), 2, "got: {needed:?}");
882        let names: Vec<&str> = needed
883            .iter()
884            .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
885            .collect();
886        assert!(names.contains(&"semantic-scholar"));
887        assert!(names.contains(&"outline"));
888        assert!(!names.contains(&"unused"), "unused server is filtered out");
889    }
890
891    #[test]
892    fn resolve_needed_mcp_servers_returns_empty_when_no_mcp_tools() {
893        let tools = vec!["Read".to_string(), "WebSearch".to_string()];
894        let spec_servers =
895            vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
896        let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
897        assert!(
898            needed.is_empty(),
899            "no mcp__-prefixed tools → empty result, got: {needed:?}"
900        );
901    }
902
903    #[test]
904    fn build_inline_agent_invoker_embeds_mcp_servers_as_lua_literal() {
905        let servers =
906            vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
907        let script = build_inline_agent_invoker(&servers);
908        match script {
909            ScriptSource::Inline { source, name } => {
910                assert!(name.ends_with(".lua"));
911                assert!(source.contains("require(\"agent\")"));
912                assert!(source.contains("mcp_servers = mcp_servers"));
913                assert!(source.contains("bus.emit(\"agent_result\""));
914                // Lua literal embed (= keys [\"name\"]=\"outline\" form)
915                assert!(source.contains("[\"name\"]=\"outline\""));
916                assert!(source.contains("[\"command\"]=\"outline-mcp\""));
917                assert!(source.contains("[\"args\"]={}"), "args empty array literal");
918            }
919            other => panic!("expected Inline, got: {other:?}"),
920        }
921    }
922
923    #[test]
924    fn build_inline_agent_invoker_with_empty_servers_still_valid() {
925        let script = build_inline_agent_invoker(&[]);
926        match script {
927            ScriptSource::Inline { source, .. } => {
928                assert!(source.contains("local mcp_servers = {}"));
929            }
930            other => panic!("expected Inline, got: {other:?}"),
931        }
932    }
933
934    #[test]
935    fn json_to_lua_literal_handles_primitives_and_nested() {
936        assert_eq!(json_to_lua_literal(&serde_json::json!(null)), "nil");
937        assert_eq!(json_to_lua_literal(&serde_json::json!(true)), "true");
938        assert_eq!(json_to_lua_literal(&serde_json::json!(42)), "42");
939        assert_eq!(json_to_lua_literal(&serde_json::json!("hi")), "\"hi\"");
940        assert_eq!(
941            json_to_lua_literal(&serde_json::json!(["a", "b"])),
942            "{\"a\", \"b\"}"
943        );
944        assert_eq!(
945            json_to_lua_literal(&serde_json::json!({"k": 1})),
946            "{[\"k\"]=1}"
947        );
948    }
949
950    #[test]
951    fn extract_prefers_content_then_response_then_whole() {
952        // (1) `content` takes priority (DefaultAgent invoker / agent.run return-value path).
953        let p = serde_json::json!({
954            "content": "Water boils at 100°C",
955            "messages": [{"role": "assistant"}],
956            "usage": {"input_tokens": 67, "output_tokens": 29},
957            "ok": true,
958        });
959        let (value, ok) = WorkerResultCaptor::extract(&p);
960        assert_eq!(value, serde_json::json!("Water boils at 100°C"));
961        assert!(ok);
962
963        // (2) No `content` → `response` (caller-script convention worker_result).
964        let p = serde_json::json!({ "ok": false, "response": {"patch": "..."} });
965        let (value, ok) = WorkerResultCaptor::extract(&p);
966        assert_eq!(value, serde_json::json!({"patch": "..."}));
967        assert!(!ok);
968
969        // (3) Neither present → the whole payload (custom shape).
970        let p = serde_json::json!({ "custom_field": 42 });
971        let (value, ok) = WorkerResultCaptor::extract(&p);
972        assert_eq!(value, serde_json::json!({"custom_field": 42}));
973        assert!(ok); // `ok` absent → defaults to true
974    }
975
976    #[tokio::test]
977    async fn captor_emits_worker_result_from_payload() {
978        let (tx, rx) = oneshot::channel();
979        let captor = WorkerResultCaptor {
980            tx: Mutex::new(Some(tx)),
981            sink: None,
982            declared_model: None,
983        };
984        let payload = serde_json::json!({ "ok": true, "response": "hello" });
985        let ack = captor
986            .call("worker_result".into(), "evt-1".into(), payload, Value::Null)
987            .await
988            .expect("handler ack");
989        assert_eq!(ack, Value::Null);
990        let wr = rx.await.expect("recv");
991        assert!(wr.ok);
992        assert_eq!(wr.value, serde_json::json!("hello"));
993    }
994
995    #[test]
996    fn extract_stats_keeps_a_partial_usage_report() {
997        // A block whose provider only surfaced a total used to have its
998        // usage dropped wholesale; partial axes now normalize instead.
999        let stats = WorkerResultCaptor::extract_stats(&serde_json::json!({
1000            "usage": {"total_tokens": 512},
1001            "num_turns": 3,
1002        }))
1003        .expect("a total-only usage still carries information");
1004        let usage = stats.usage.expect("usage");
1005        assert_eq!(usage.total_tokens, 512);
1006        assert_eq!(usage.input_tokens, 0);
1007        assert_eq!(
1008            stats.adapter_data,
1009            Some(serde_json::json!({"total_tokens": 512})),
1010            "the raw usage object still rides along verbatim"
1011        );
1012
1013        // Splits without a total keep deriving it.
1014        let stats = WorkerResultCaptor::extract_stats(&serde_json::json!({
1015            "usage": {"input_tokens": 10, "output_tokens": 4},
1016        }))
1017        .expect("splits-only usage");
1018        assert_eq!(stats.usage.expect("usage").total_tokens, 14);
1019
1020        // No token axis at all → no usage (num_turns alone still counts).
1021        let stats = WorkerResultCaptor::extract_stats(&serde_json::json!({
1022            "usage": {},
1023            "num_turns": 1,
1024        }))
1025        .expect("num_turns alone is still a report");
1026        assert!(stats.usage.is_none(), "an empty usage object records none");
1027    }
1028
1029    // ─── declared model → per-step stats sidecar ─────────────────────────
1030
1031    /// Run one payload through a captor carrying `declared_model` and
1032    /// return the resulting `stats.model`.
1033    async fn captured_model(declared: Option<&str>, payload: Value) -> Option<String> {
1034        let (tx, rx) = oneshot::channel();
1035        let captor = WorkerResultCaptor {
1036            tx: Mutex::new(Some(tx)),
1037            sink: None,
1038            declared_model: declared.map(str::to_string),
1039        };
1040        captor
1041            .call("agent_result".into(), "evt-1".into(), payload, Value::Null)
1042            .await
1043            .expect("handler ack");
1044        let wr = rx.await.expect("recv");
1045        wr.stats
1046            .expect("worker_kind alone guarantees a sidecar")
1047            .model
1048    }
1049
1050    /// `agent.run` reports no model, so the declared `profile.model` is
1051    /// what lands in the sidecar — the gap that left `StepEntry.stats
1052    /// .model` permanently `None` on this backend.
1053    #[tokio::test]
1054    async fn declared_model_lands_in_the_stats_sidecar() {
1055        let payload = serde_json::json!({
1056            "content": "done",
1057            "usage": {"input_tokens": 10, "output_tokens": 4},
1058            "num_turns": 2,
1059        });
1060        assert_eq!(
1061            captured_model(Some("opus"), payload).await,
1062            Some("opus".to_string())
1063        );
1064    }
1065
1066    /// The fallback does not depend on the usage rail: a caller-script
1067    /// `worker_result` shape (no `usage`, no `num_turns`) still gets the
1068    /// declared model, because `ensure_worker_kind` has already created
1069    /// the sidecar.
1070    #[tokio::test]
1071    async fn declared_model_lands_even_without_usage_in_the_payload() {
1072        let payload = serde_json::json!({ "ok": true, "response": "hello" });
1073        assert_eq!(
1074            captured_model(Some("sonnet"), payload).await,
1075            Some("sonnet".to_string())
1076        );
1077    }
1078
1079    /// A runtime-reported `model` is what actually served the attempt, so
1080    /// it outranks the declaration.
1081    #[tokio::test]
1082    async fn runtime_reported_model_wins_over_the_declaration() {
1083        let payload = serde_json::json!({
1084            "content": "done",
1085            "model": "claude-runtime-1",
1086            "usage": {"input_tokens": 10, "output_tokens": 4},
1087        });
1088        assert_eq!(
1089            captured_model(Some("opus"), payload).await,
1090            Some("claude-runtime-1".to_string())
1091        );
1092
1093        // …including when the payload carries nothing else the stats
1094        // extractor keys on.
1095        let payload = serde_json::json!({ "content": "done", "model": "claude-runtime-1" });
1096        assert_eq!(
1097            captured_model(Some("opus"), payload).await,
1098            Some("claude-runtime-1".to_string())
1099        );
1100    }
1101
1102    /// No declaration and no runtime report = no attribution invented.
1103    #[tokio::test]
1104    async fn no_declared_model_leaves_the_sidecar_model_empty() {
1105        let payload = serde_json::json!({ "ok": true, "response": "hello" });
1106        assert_eq!(captured_model(None, payload).await, None);
1107    }
1108
1109    #[tokio::test]
1110    async fn factory_builds_prompt_based_agent_when_script_path_absent() {
1111        use crate::blueprint::compiler::SpawnerFactory;
1112        use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
1113
1114        let factory = AgentBlockInProcessSpawnerFactory::new();
1115        let ad = AgentDef {
1116            name: "writer".into(),
1117            kind: AgentKind::AgentBlock,
1118            spec: serde_json::json!({}),
1119            profile: Some(AgentProfile {
1120                system_prompt: "You are writer.".into(),
1121                ..Default::default()
1122            }),
1123            meta: None,
1124            runner: None,
1125            runner_ref: None,
1126            verdict: None,
1127            lints: None,
1128        };
1129        let _spawner = factory.build(&ad, None).expect("factory build");
1130        // = ScriptSource::Inline path (self-hosted invoker, mcp_servers embed);
1131        // the host_handler single sink captures every event kind.
1132    }
1133
1134    // ─── GH #86: effective tool grant ─────────────────────────────────────
1135
1136    fn agent_block_def(name: &str, spec: Value, tools: &[&str]) -> crate::blueprint::AgentDef {
1137        use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
1138        AgentDef {
1139            name: name.into(),
1140            kind: AgentKind::AgentBlock,
1141            spec,
1142            profile: Some(AgentProfile {
1143                system_prompt: "You are an auditor.".into(),
1144                tools: tools.iter().map(|t| t.to_string()).collect(),
1145                ..Default::default()
1146            }),
1147            meta: None,
1148            runner: None,
1149            runner_ref: None,
1150            verdict: None,
1151            lints: None,
1152        }
1153    }
1154
1155    #[test]
1156    fn mcp_tools_of_keeps_only_server_selecting_names() {
1157        let tools = vec![
1158            "Read".to_string(),
1159            "mcp__outline__list_docs".to_string(),
1160            "WebSearch".to_string(),
1161        ];
1162        assert_eq!(mcp_tools_of(&tools), vec!["mcp__outline__list_docs"]);
1163        assert!(mcp_tools_of(&["Read".to_string()]).is_empty());
1164    }
1165
1166    /// PromptBasedAgent mode is where the grant is enforced: only the
1167    /// `spec.mcp_servers` entries named by the effective set (=
1168    /// `profile.tools`, which the compiler has already overwritten from a
1169    /// declared Runner) reach the invoker.
1170    ///
1171    /// Enforcement is per **server**, not per tool — granting
1172    /// `mcp__outline__list_docs` embeds the whole `outline` server, and the
1173    /// SDK exposes every tool of a connected server to the model.
1174    #[tokio::test]
1175    async fn effective_grant_narrows_the_embedded_mcp_servers() {
1176        use crate::blueprint::compiler::SpawnerFactory;
1177
1178        let ad = agent_block_def(
1179            "auditor",
1180            serde_json::json!({
1181                "mcp_servers": [
1182                    {"name": "outline", "command": "outline-mcp", "args": []},
1183                    {"name": "semantic-scholar", "command": "ss-mcp", "args": []},
1184                ]
1185            }),
1186            &["mcp__outline__list_docs"],
1187        );
1188
1189        // The pure resolution the factory performs, asserted directly (the
1190        // built `Arc<dyn SpawnerAdapter>` is opaque).
1191        let effective = ad.profile.as_ref().unwrap().tools.clone();
1192        let servers = resolve_needed_mcp_servers(
1193            &effective,
1194            ad.spec["mcp_servers"].as_array().expect("array"),
1195        );
1196        let names: Vec<&str> = servers
1197            .iter()
1198            .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
1199            .collect();
1200        assert_eq!(
1201            names,
1202            vec!["outline"],
1203            "semantic-scholar is declared in spec but not selected by the grant"
1204        );
1205
1206        // And the build itself succeeds on this (PromptBased) path.
1207        AgentBlockInProcessSpawnerFactory::new()
1208            .build(&ad, None)
1209            .expect("PromptBasedAgent mode accepts an MCP grant");
1210    }
1211
1212    /// ScriptBasedAgent mode cannot enforce an MCP grant (the script drives
1213    /// its own `mcp.connect`), so declared `mcp__` entries are rejected
1214    /// rather than silently ignored.
1215    #[tokio::test]
1216    async fn script_mode_rejects_a_declared_mcp_grant() {
1217        use crate::blueprint::compiler::{CompileError, SpawnerFactory};
1218
1219        let ad = agent_block_def(
1220            "gate-danger",
1221            serde_json::json!({ "script_path": "gate.lua" }),
1222            &["mcp__outline__list_docs"],
1223        );
1224        let err = AgentBlockInProcessSpawnerFactory::new()
1225            .build(&ad, None)
1226            .err()
1227            .expect("must reject");
1228        match err {
1229            CompileError::InvalidSpec { name, msg } => {
1230                assert_eq!(name, "gate-danger");
1231                assert!(msg.contains("mcp.connect"), "explains why: {msg}");
1232                assert!(
1233                    msg.contains("PromptBasedAgent"),
1234                    "names the actionable alternative: {msg}"
1235                );
1236            }
1237            other => panic!("expected InvalidSpec, got: {other:?}"),
1238        }
1239    }
1240
1241    /// The guard is scoped to `mcp__` entries: an empty grant (the issue's
1242    /// own repro BP) and an inert-only grant (an agent.md `tools: Read,
1243    /// WebSearch` line, which compiled before the guard existed) both still
1244    /// build in script mode.
1245    #[tokio::test]
1246    async fn script_mode_accepts_empty_and_inert_grants() {
1247        use crate::blueprint::compiler::SpawnerFactory;
1248
1249        let spec = serde_json::json!({ "script_path": "gate.lua" });
1250        for tools in [&[][..], &["Read", "WebSearch"][..]] {
1251            let ad = agent_block_def("gate-danger", spec.clone(), tools);
1252            AgentBlockInProcessSpawnerFactory::new()
1253                .build(&ad, None)
1254                .unwrap_or_else(|e| panic!("script mode must accept tools {tools:?}: {e}"));
1255        }
1256    }
1257
1258    #[tokio::test]
1259    async fn factory_builds_script_based_agent_when_script_path_present() {
1260        use crate::blueprint::compiler::SpawnerFactory;
1261        use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
1262
1263        let factory = AgentBlockInProcessSpawnerFactory::new();
1264        let ad = AgentDef {
1265            name: "patch-spawner".into(),
1266            kind: AgentKind::AgentBlock,
1267            spec: serde_json::json!({
1268                "script_path": "assets/operator_scripts/blueprint_patch_spawner.lua",
1269                "project_root": ".",
1270            }),
1271            profile: Some(AgentProfile {
1272                system_prompt: "Patch generator.".into(),
1273                ..Default::default()
1274            }),
1275            meta: None,
1276            runner: None,
1277            runner_ref: None,
1278            verdict: None,
1279            lints: None,
1280        };
1281        let _spawner = factory.build(&ad, None).expect("factory build");
1282        // = ScriptSource::Path path; caller-provided script; host_handler single sink.
1283    }
1284
1285    // ─── Issue #17: `project_root` priority chain ─────────────────────────
1286
1287    #[test]
1288    fn resolve_spec_project_root_uses_spec_value_when_present() {
1289        let resolved =
1290            resolve_spec_project_root(&serde_json::json!({ "project_root": "/spec-root" }));
1291        assert_eq!(resolved, PathBuf::from("/spec-root"));
1292    }
1293
1294    #[test]
1295    fn resolve_spec_project_root_falls_back_to_env_current_dir_when_spec_absent() {
1296        let resolved = resolve_spec_project_root(&serde_json::json!({}));
1297        assert_eq!(
1298            resolved,
1299            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1300        );
1301    }
1302
1303    /// A view carrying exactly the task-context fields under test. Built
1304    /// through the real `AgentContextView::from_ctx` so the field names
1305    /// stay bound to the canonical `ctx.meta.runtime` keys rather than to
1306    /// a hand-written literal that could drift from them.
1307    fn view_with(pairs: &[(&str, Value)]) -> AgentContextView {
1308        let mut ctx = crate::core::ctx::Ctx::new(
1309            crate::types::StepId::parse("ST-project-root").unwrap(),
1310            1,
1311            "writer",
1312        );
1313        for (k, v) in pairs {
1314            ctx.meta.runtime.insert((*k).to_string(), v.clone());
1315        }
1316        AgentContextView::from_ctx(&ctx)
1317    }
1318
1319    // ─── project_root priority chain (issue #17, now off the seam) ────────
1320
1321    #[test]
1322    fn project_root_falls_back_to_spec_when_the_view_carries_neither() {
1323        let view = view_with(&[]);
1324        let resolved = resolve_project_root(Some(&view), Path::new("/spec-root"));
1325        assert_eq!(resolved, PathBuf::from("/spec-root"));
1326    }
1327
1328    /// No `Ctx` on the caller path at all (`inv.context == None`) is the
1329    /// same outcome as an empty view — the compile-time fallback stands.
1330    #[test]
1331    fn project_root_falls_back_to_spec_without_a_view() {
1332        assert_eq!(
1333            resolve_project_root(None, Path::new("/spec-root")),
1334            PathBuf::from("/spec-root")
1335        );
1336    }
1337
1338    #[test]
1339    fn project_root_prefers_the_view_over_spec() {
1340        let view = view_with(&[(TASK_PROJECT_ROOT_KEY, serde_json::json!("/ctx-root"))]);
1341        let resolved = resolve_project_root(Some(&view), Path::new("/spec-root"));
1342        assert_eq!(resolved, PathBuf::from("/ctx-root"));
1343    }
1344
1345    #[test]
1346    fn project_root_prefers_work_dir_over_project_root() {
1347        let view = view_with(&[
1348            (TASK_PROJECT_ROOT_KEY, serde_json::json!("/ctx-root")),
1349            (TASK_WORK_DIR_KEY, serde_json::json!("/ctx-work")),
1350        ]);
1351        let resolved = resolve_project_root(Some(&view), Path::new("/spec-root"));
1352        assert_eq!(resolved, PathBuf::from("/ctx-work"));
1353    }
1354
1355    // ─── GH #86: task_metadata delivery via SDK extra_globals ─────────────
1356
1357    /// An `artifact` emit must reach the sink as an
1358    /// `OutputEvent::Artifact` AND leave the invocation running, so the
1359    /// script can stage parts and still finish with its terminal emit.
1360    #[tokio::test]
1361    async fn artifact_kind_stages_a_named_part_without_completing() {
1362        use crate::worker::output::{ContentRef, OutputEvent, OutputSink};
1363
1364        #[derive(Default)]
1365        struct RecordingSink(Mutex<Vec<OutputEvent>>);
1366        #[async_trait]
1367        impl OutputSink for RecordingSink {
1368            async fn emit(&self, event: OutputEvent) -> Result<(), crate::EngineError> {
1369                self.0.lock().unwrap().push(event);
1370                Ok(())
1371            }
1372        }
1373
1374        let sink = Arc::new(RecordingSink::default());
1375        let (tx, mut rx) = oneshot::channel();
1376        let captor = WorkerResultCaptor {
1377            tx: Mutex::new(Some(tx)),
1378            sink: Some(sink.clone()),
1379            declared_model: None,
1380        };
1381
1382        captor
1383            .call(
1384                ARTIFACT_EVENT_KIND.into(),
1385                "evt-1".into(),
1386                serde_json::json!({ "name": "verdict", "content": "PASS" }),
1387                Value::Null,
1388            )
1389            .await
1390            .expect("staging must succeed");
1391
1392        let staged = sink.0.lock().unwrap().clone();
1393        assert_eq!(staged.len(), 1, "exactly one artifact staged");
1394        match &staged[0] {
1395            OutputEvent::Artifact { name, content } => {
1396                assert_eq!(name, "verdict");
1397                // `ContentRef` is not `PartialEq`; match the variant.
1398                match content {
1399                    ContentRef::Inline { value } => {
1400                        assert_eq!(value, &serde_json::json!("PASS"))
1401                    }
1402                    other => panic!("expected Inline content, got: {other:?}"),
1403                }
1404            }
1405            other => panic!("expected Artifact, got: {other:?}"),
1406        }
1407        assert!(
1408            rx.try_recv().is_err(),
1409            "an artifact emit must NOT complete the invocation"
1410        );
1411
1412        // The terminal emit still lands afterwards.
1413        captor
1414            .call(
1415                "worker_result".into(),
1416                "evt-2".into(),
1417                serde_json::json!({ "ok": true, "response": "done" }),
1418                Value::Null,
1419            )
1420            .await
1421            .expect("terminal emit");
1422        assert_eq!(
1423            rx.await.expect("recv").value,
1424            serde_json::json!("done"),
1425            "the non-reserved kind still completes the invocation"
1426        );
1427    }
1428
1429    /// An `artifact` emit with no `name` cannot address a part, so it is
1430    /// an error back to the script rather than a silent drop.
1431    #[tokio::test]
1432    async fn artifact_without_a_name_is_reported_to_the_script() {
1433        let (tx, _rx) = oneshot::channel();
1434        let captor = WorkerResultCaptor {
1435            tx: Mutex::new(Some(tx)),
1436            sink: None,
1437            declared_model: None,
1438        };
1439        let err = captor
1440            .call(
1441                ARTIFACT_EVENT_KIND.into(),
1442                "evt-1".into(),
1443                serde_json::json!({ "content": "PASS" }),
1444                Value::Null,
1445            )
1446            .await
1447            .expect_err("a nameless artifact must fail loud");
1448        assert!(
1449            format!("{err}").contains("name"),
1450            "names the missing field: {err}"
1451        );
1452    }
1453
1454    // ─── GH #86: the shared view → Lua-global mapping ─────────────────────
1455
1456    #[test]
1457    fn context_globals_renders_task_metadata_and_agent_ctx() {
1458        let mut view = view_with(&[(TASK_METADATA_KEY, serde_json::json!({"issue": 86}))]);
1459        view.extra.insert(
1460            "org_conventions".to_string(),
1461            serde_json::json!("two-space indent"),
1462        );
1463        let globals = context_globals(Some(&view));
1464        assert_eq!(
1465            globals.get(TASK_METADATA_GLOBAL),
1466            Some(&serde_json::json!({"issue": 86}))
1467        );
1468        assert_eq!(
1469            globals.get(AGENT_CTX_GLOBAL),
1470            Some(&serde_json::json!({"org_conventions": "two-space indent"})),
1471            "Blueprint-declared agent ctx must reach the in-process lane too"
1472        );
1473    }
1474
1475    /// Absent fields contribute no entry, so a script sees `nil` and can
1476    /// branch on presence — an empty table would be indistinguishable from
1477    /// "the author declared an empty ctx".
1478    #[test]
1479    fn context_globals_omits_absent_fields() {
1480        assert!(context_globals(None).is_empty(), "no view → no globals");
1481        assert!(
1482            context_globals(Some(&view_with(&[]))).is_empty(),
1483            "empty view → no globals"
1484        );
1485
1486        let view = view_with(&[(TASK_METADATA_KEY, serde_json::json!({"issue": 86}))]);
1487        let globals = context_globals(Some(&view));
1488        assert!(globals.contains_key(TASK_METADATA_GLOBAL));
1489        assert!(
1490            !globals.contains_key(AGENT_CTX_GLOBAL),
1491            "an empty `extra` must not render an empty _AGENT_CTX table"
1492        );
1493    }
1494
1495    /// Neither global may collide with an SDK-reserved name, and the two
1496    /// must not collide with each other.
1497    #[test]
1498    fn context_globals_use_names_the_sdk_does_not_reserve() {
1499        for name in [TASK_METADATA_GLOBAL, AGENT_CTX_GLOBAL] {
1500            for reserved in ["_PROMPT", "_CONTEXT", "_SCRIPT_NAME"] {
1501                assert_ne!(name, reserved);
1502            }
1503        }
1504        assert_ne!(TASK_METADATA_GLOBAL, AGENT_CTX_GLOBAL);
1505    }
1506
1507    /// The global name must not collide with the three the SDK reserves
1508    /// for itself — a collision would be silently overwritten one way or
1509    /// the other depending on injection order.
1510    #[test]
1511    fn task_metadata_global_does_not_shadow_an_sdk_reserved_name() {
1512        for reserved in ["_PROMPT", "_CONTEXT", "_SCRIPT_NAME"] {
1513            assert_ne!(TASK_METADATA_GLOBAL, reserved);
1514        }
1515    }
1516
1517    /// Script mode keeps `ScriptSource::Path`: delivering `task_metadata`
1518    /// through `extra_globals` means the chunk itself is never rewritten,
1519    /// so a caller script's own directory stays on `package.path` (sibling
1520    /// `require` keeps working) and its Lua stack-trace line numbers are
1521    /// unshifted. The `sibling_require_resolves_*` e2e is the behavioural
1522    /// half of this claim.
1523    #[tokio::test]
1524    async fn script_mode_never_rewrites_the_caller_chunk() {
1525        use crate::blueprint::compiler::SpawnerFactory;
1526
1527        let ad = agent_block_def(
1528            "gate-danger",
1529            serde_json::json!({ "script_path": "/nonexistent/gate.lua" }),
1530            &[],
1531        );
1532        // A build must succeed without touching the file: the path is
1533        // handed to the SDK verbatim, exactly as before GH #86.
1534        AgentBlockInProcessSpawnerFactory::new()
1535            .build(&ad, None)
1536            .expect("script mode must not read the script at build time");
1537    }
1538}