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