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}