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//! ## SDK paths introduced from v0.22.0 through v0.27.0
36//!
37//! | Version | Feature | Use case |
38//! |---|---|---|
39//! | v0.22.0 | `bus.emit(kind, payload, id?)` Lua bridge | script → host event push |
40//! | v0.23.0 | `BlockConfig.host_handlers` | Pre-install a Rust handler on the EventBus |
41//! | v0.24.0 | `BlockConfig.auto_serve_bus` | SDK embed drives the dispatcher in the background |
42//! | v0.25.0 | `BlockConfig.shutdown_token` + `BlockError::Cancelled` + `Send` on `run()` | `tokio::spawn` and external cancel |
43//! | v0.26.0 | `ScriptSource` / `PromptSource` / `SecretKeySource` enums plus the embedded `DefaultAgent` invoker (breaking) | Script becomes optional at the SDK level |
44//! | v0.27.0 | Embed the `compile_loop` StdPkg into core | `require("compile_loop")` hits directly |
45
46use crate::worker::adapter::{InProcSpawner, WorkerError, WorkerInvocation, WorkerResult};
47use agent_block_core::bus::dispatcher::Handler;
48use agent_block_core::host::{PromptSource, ScriptSource};
49use agent_block_core::{run, BlockConfig};
50use agent_block_types::error::BlockError;
51use async_trait::async_trait;
52use serde_json::Value;
53use std::collections::HashMap;
54use std::path::PathBuf;
55use std::sync::{Arc, Mutex};
56use std::time::Duration;
57use tokio::sync::oneshot;
58
59/// Host-side handler that fires when the Lua script (or the
60/// DefaultAgent invoker) calls `bus.emit(<kind>, payload)`. It folds
61/// the payload into a [`WorkerResult`] and forwards it on the
62/// [`oneshot::Sender`].
63///
64/// This is **an AgentBlock-internal helper**. Different SDK paths use
65/// different event names and payload shapes — the DefaultAgent
66/// invoker's `agent_result` event carries the entire `agent.run`
67/// return value (`{content, messages, num_turns, ok, usage}`), while a
68/// caller script's `worker_result` event carries `{ok, response}`. The
69/// captor keeps those quirks contained and **normalises them**, so
70/// callers (flow.ir, the engine, higher-level Workers) always see the
71/// same single form: "the raw LLM response is `WorkerResult.value`".
72///
73/// Value extraction priority (the normalisation policy that hides the
74/// SDK quirks):
75///
76/// 1. `payload.content` — from the DefaultAgent invoker / `agent.run`
77/// return value; carried as a string.
78/// 2. `payload.response` — the caller script's `worker_result`
79/// convention; free-form.
80/// 3. Fallback: the whole payload — for custom shapes that carry
81/// neither of the above.
82///
83/// `ok` extraction: `payload.ok` if present, otherwise `true` — the
84/// DefaultAgent invoker includes `ok`, so this recovers it.
85///
86/// This is the core of the observation #2 fix. The previous
87/// implementation did not consult (1); it only fell back
88/// `(2) → (3)`. On the DefaultAgent path that pushed the whole
89/// `agent_result` object into `WorkerResult.value`, which then rode
90/// through the chain and hit the next step's prompt via
91/// JSON-stringification — burning 50-60% of the tokens on
92/// boilerplate. Pulling out (1) first normalises the chain to a single
93/// LLM raw-text carry and brings the Worker pattern up to the token
94/// efficiency of the Phase 3 WS Operator path.
95struct WorkerResultCaptor {
96 tx: Mutex<Option<oneshot::Sender<WorkerResult>>>,
97}
98
99impl WorkerResultCaptor {
100 /// SDK-quirks normalisation: extract `(value, ok)` from a
101 /// `bus.emit` payload. `pub(crate)` so both callers and unit tests
102 /// can reach it.
103 fn extract(payload: &Value) -> (Value, bool) {
104 let ok = payload.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
105 let value = payload
106 .get("content")
107 .cloned()
108 .or_else(|| payload.get("response").cloned())
109 .unwrap_or_else(|| payload.clone());
110 (value, ok)
111 }
112}
113
114#[async_trait]
115impl Handler for WorkerResultCaptor {
116 async fn call(
117 &self,
118 _kind: String,
119 _id: String,
120 payload: Value,
121 _meta: Value,
122 ) -> Result<Value, BlockError> {
123 let (value, ok) = Self::extract(&payload);
124 let wr = WorkerResult { value, ok };
125 if let Ok(mut guard) = self.tx.lock() {
126 if let Some(tx) = guard.take() {
127 let _ = tx.send(wr);
128 }
129 }
130 Ok(Value::Null)
131 }
132}
133
134/// Settings baked per `AgentDef` — the static portion of one
135/// invocation.
136///
137/// v0.28.0 adopted `BlockConfig.host_handler` (a kind-agnostic
138/// single sink backed by `EventBus::on_any`); the older
139/// `result_event_kind: String` field (which required the caller /
140/// script to coordinate a kind string) is gone. One captor per
141/// invocation is enough, so a single sink is enough.
142#[derive(Clone)]
143struct AgentBlockSettings {
144 /// Either a PromptBasedAgent — `ScriptSource::Inline` with an
145 /// in-line invoker that embeds `mcp_servers` — or a
146 /// ScriptBasedAgent (`ScriptSource::Path(...)`, a caller-supplied
147 /// script).
148 script: ScriptSource,
149 project_root: PathBuf,
150 mcp_rpc_timeout: Duration,
151 /// Agent persona — the `system_prompt` composed from the agent.md
152 /// body and frontmatter. `None` maps to `BlockConfig.context = None`
153 /// for backwards compatibility with the old path.
154 profile_context: Option<String>,
155}
156
157/// One invocation's worth of an `agent-block-core` SDK call — the
158/// `WorkerFn` body.
159///
160/// Registers the result captor through the v0.28.0 `host_handler`
161/// (single, kind-agnostic fallback). The plural `host_handlers`
162/// (string-keyed routing) is not needed — one captor per invocation is
163/// enough, and there is no script-side event-kind string to coordinate.
164async fn run_agent_block_worker(
165 settings: Arc<AgentBlockSettings>,
166 inv: WorkerInvocation,
167) -> Result<WorkerResult, WorkerError> {
168 let (tx, rx) = oneshot::channel();
169 let captor: Arc<dyn Handler> = Arc::new(WorkerResultCaptor {
170 tx: Mutex::new(Some(tx)),
171 });
172
173 // Bridge the shutdown token: forward `WorkerInvocation.cancel_token`
174 // into the SDK's `shutdown_token` if one is set; otherwise use a
175 // fresh token (no external cancel).
176 let shutdown_token = inv.cancel_token.clone().unwrap_or_default();
177 let config = BlockConfig {
178 script: settings.script.clone(),
179 project_root: settings.project_root.clone(),
180 relay_url: None,
181 secret_key: None,
182 mcp_rpc_timeout: settings.mcp_rpc_timeout,
183 prompt: Some(PromptSource::Inline(inv.prompt)),
184 context: settings.profile_context.clone().map(PromptSource::Inline),
185 host_handlers: HashMap::new(),
186 host_handler: Some(captor),
187 auto_serve_bus: true,
188 shutdown_token: Some(shutdown_token.clone()),
189 };
190
191 let run_handle = tokio::spawn(run(config));
192 let run_result = run_handle
193 .await
194 .map_err(|e| WorkerError::Failed(format!("agent-block task join: {e}")))?;
195 run_result.map_err(|e| WorkerError::Failed(format!("agent-block run failed: {e}")))?;
196
197 rx.await.map_err(|_| {
198 WorkerError::Failed("agent-block script finished without emitting result via bus".into())
199 })
200}
201
202// ─── tools / mcp_servers resolution ───────────────────────────────────────
203
204/// Cross-reference `profile.tools` (the CSV on the `tools:` line of an
205/// agent.md frontmatter) with `spec.mcp_servers` (the `"server name" →
206/// command + args` mapping provided by the `AgentDef` literal cascade)
207/// and resolve the `mcp_servers` config actually exposed to the LLM
208/// for this invocation.
209///
210/// Algorithm:
211///
212/// 1. Extract `mcp__<server>__<tool>` patterns from `profile.tools`;
213/// collect the `<server>` names.
214/// 2. Filter `spec.mcp_servers` to just the entries whose name is in
215/// that set.
216///
217/// This is the response to observation #3 — do not hand the LLM
218/// `mcp_servers` it does not need (only the servers the profile
219/// explicitly asks for), and equally do not expose servers the
220/// profile does not know about even if the spec carries them
221/// (caller intent wins).
222///
223/// CC built-in tools (non-`mcp__`-prefixed names like `Read` / `Write`
224/// / `WebSearch`) are out of scope here; handling those lives in a
225/// different layer — a carry that would come through a future
226/// `opts.extra_tools` Rust implementation.
227pub fn resolve_needed_mcp_servers(
228 profile_tools: &[String],
229 spec_mcp_servers: &[Value],
230) -> Vec<Value> {
231 use std::collections::HashSet;
232 // Step 1: server names from `mcp__<server>__<tool>` patterns in
233 // `profile.tools`.
234 let needed: HashSet<&str> = profile_tools
235 .iter()
236 .filter_map(|t| {
237 let rest = t.strip_prefix("mcp__")?;
238 // Split `<server>__<tool>` at the first `__`.
239 let idx = rest.find("__")?;
240 Some(&rest[..idx])
241 })
242 .collect();
243
244 // Step 2: filter `spec.mcp_servers` down to entries whose name is
245 // in `needed`.
246 spec_mcp_servers
247 .iter()
248 .filter(|cfg| {
249 cfg.get("name")
250 .and_then(|n| n.as_str())
251 .map(|name| needed.contains(name))
252 .unwrap_or(false)
253 })
254 .cloned()
255 .collect()
256}
257
258/// Build the inline Lua script used on the PromptBasedAgent path (when
259/// `spec.script_path` is absent). Instead of the SDK's embedded
260/// `DEFAULT_AGENT_INVOKER` (which passes no tools), this embeds
261/// `mcp_servers` as a Lua literal table and hands it to `agent.run`.
262///
263/// This is the core of the observation #3 fix. The old DefaultAgent
264/// path had no way to deliver a frontmatter `tools:` line to the SDK.
265/// This inline path bakes the `profile.tools` → `mcp_servers` config
266/// into the Lua source, so the LLM can actually make tool calls.
267///
268/// The JSON-stringify + `std.json.decode` route was ruled out because
269/// the SDK environment cannot `require` the `std` module (no
270/// `package.preload['std']` field), so we take the JSON → Lua-literal
271/// conversion on the Rust side and embed the result directly. The
272/// event name is `agent_result` — the same convention the SDK's
273/// internal `DEFAULT_AGENT_INVOKER` uses.
274pub fn build_inline_agent_invoker(mcp_servers: &[Value]) -> ScriptSource {
275 let mcp_lua = json_array_to_lua_literal(mcp_servers);
276 let source = format!(
277 r##"local agent = require("agent")
278local mcp_servers = {mcp_lua}
279local r = agent.run({{
280 prompt = _PROMPT,
281 system = _CONTEXT,
282 mcp_servers = mcp_servers,
283}})
284bus.emit("agent_result", r)
285"##
286 );
287 ScriptSource::Inline {
288 source,
289 name: "mlua_swarm_engine_default_agent_invoker.lua".into(),
290 }
291}
292
293/// Convert a JSON `Value` into a Lua literal expression, for embedding
294/// into the inline script. Lua string escaping is delegated to Rust's
295/// `{:?}` `Debug` output — Lua syntax is compatible with the escapes
296/// it produces (`"`, `\\`, `\n`, `\r`, `\t`, and so on). Edge cases
297/// like `\0` or unusual Unicode escapes are outside the scope of this
298/// use.
299fn json_to_lua_literal(v: &Value) -> String {
300 match v {
301 Value::Null => "nil".to_string(),
302 Value::Bool(b) => b.to_string(),
303 Value::Number(n) => n.to_string(),
304 Value::String(s) => format!("{s:?}"),
305 Value::Array(arr) => {
306 let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
307 format!("{{{}}}", items.join(", "))
308 }
309 Value::Object(map) => {
310 let items: Vec<String> = map
311 .iter()
312 .map(|(k, v)| format!("[{k:?}]={}", json_to_lua_literal(v)))
313 .collect();
314 format!("{{{}}}", items.join(", "))
315 }
316 }
317}
318
319/// Convert a `Vec<Value>` into a Lua literal sequence. An empty array
320/// becomes `{}` — a Lua empty table.
321fn json_array_to_lua_literal(arr: &[Value]) -> String {
322 if arr.is_empty() {
323 return "{}".to_string();
324 }
325 let items: Vec<String> = arr.iter().map(json_to_lua_literal).collect();
326 format!("{{{}}}", items.join(", "))
327}
328
329// ─── SpawnerFactory ───────────────────────────────────────────────────────
330
331/// The `SpawnerFactory` for AgentBlock. `KIND = AgentKind::AgentBlock`.
332///
333/// **State-less.** One factory per process; every `AgentDef` uses it
334/// as a shared builder. Per-agent specialisation stays **entirely
335/// inside `AgentDef.spec` + `AgentDef.profile`** — the old
336/// `default_script_path` / `default_project_root` fields are gone.
337///
338/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` — an
339/// AgentBlock worker on the InProcess adapter.
340pub struct AgentBlockInProcessSpawnerFactory;
341
342impl Default for AgentBlockInProcessSpawnerFactory {
343 fn default() -> Self {
344 Self
345 }
346}
347
348impl AgentBlockInProcessSpawnerFactory {
349 /// Stateless constructor — equivalent to `Default::default()`.
350 pub fn new() -> Self {
351 Self
352 }
353}
354
355impl crate::blueprint::compiler::SpawnerFactoryKind for AgentBlockInProcessSpawnerFactory {
356 const KIND: crate::blueprint::AgentKind = crate::blueprint::AgentKind::AgentBlock;
357 type Worker = AgentBlockWorker;
358}
359
360impl crate::blueprint::compiler::SpawnerFactory for AgentBlockInProcessSpawnerFactory {
361 fn build(
362 &self,
363 agent_def: &crate::blueprint::AgentDef,
364 _hint: Option<&Value>,
365 ) -> Result<
366 Arc<dyn crate::worker::adapter::SpawnerAdapter>,
367 crate::blueprint::compiler::CompileError,
368 > {
369 let agent_name = agent_def.name.clone();
370 let spec = &agent_def.spec;
371
372 // Resolve the actual mcp_servers config to pass to the real LLM by
373 // combining profile.tools (the `tools:` line of the agent.md
374 // frontmatter) with spec.mcp_servers (the first axis of AgentDef
375 // literal cascade — a "server name → command + args" mapping). The
376 // result is JSON-embedded into the Lua source by
377 // build_inline_agent_invoker and flows into `agent.run({mcp_servers=...})`.
378 let profile_tools: Vec<String> = agent_def
379 .profile
380 .as_ref()
381 .map(|p| p.tools.clone())
382 .unwrap_or_default();
383 let spec_mcp_servers: Vec<Value> = spec
384 .get("mcp_servers")
385 .and_then(|v| v.as_array())
386 .cloned()
387 .unwrap_or_default();
388 let needed_mcp_servers = resolve_needed_mcp_servers(&profile_tools, &spec_mcp_servers);
389
390 // script: `spec.script_path` absent → PromptBasedAgent (the new Inline
391 // path, embedding tools and calling agent.run); present →
392 // ScriptBasedAgent (a caller-provided script path where tools
393 // are the caller's responsibility). Event-kind string
394 // dependency was retired — the `host_handler` single sink
395 // captures every kind.
396 let script = match spec.get("script_path").and_then(|v| v.as_str()) {
397 Some(s) => ScriptSource::Path(PathBuf::from(s)),
398 None => build_inline_agent_invoker(&needed_mcp_servers),
399 };
400
401 let project_root = match spec.get("project_root").and_then(|v| v.as_str()) {
402 Some(s) => PathBuf::from(s),
403 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
404 };
405 let mcp_rpc_timeout = match spec.get("mcp_rpc_timeout_ms").and_then(|v| v.as_u64()) {
406 Some(ms) => Duration::from_millis(ms),
407 None => Duration::from_secs(30),
408 };
409 let profile_context = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
410
411 let settings = Arc::new(AgentBlockSettings {
412 script,
413 project_root,
414 mcp_rpc_timeout,
415 profile_context,
416 });
417
418 let worker_fn: crate::worker::adapter::WorkerFn = Arc::new(move |inv| {
419 let settings = settings.clone();
420 Box::pin(run_agent_block_worker(settings, inv))
421 });
422
423 let mut sp: InProcSpawner<AgentBlockWorker> = InProcSpawner::<AgentBlockWorker>::typed();
424 sp.registry.insert(agent_name, worker_fn);
425 Ok(Arc::new(sp))
426 }
427}
428
429/// Concrete Worker type for the AgentBlock kind — the handle for an
430/// LLM call routed through the `agent-block-core` SDK. Embeds a
431/// `WorkerJoinHandler` to carry the async signal. The intent is to
432/// eventually keep the SDK-specific quirks — the `agent_result` event
433/// name, payload shape, shutdown-token bridging, agent_result.content
434/// normalisation — contained inside this struct. Today it lands as a
435/// thin shape holding only the async signal; Phase B adds the
436/// normalisation layer here and structurally eliminates the
437/// token-boilerplate waste observed in observation #2.
438pub struct AgentBlockWorker {
439 /// The completion-signal handle for this agent-block SDK call's
440 /// spawned task.
441 pub handler: crate::worker::WorkerJoinHandler,
442}
443
444impl From<crate::worker::WorkerJoinHandler> for AgentBlockWorker {
445 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
446 Self { handler }
447 }
448}
449
450#[async_trait]
451impl crate::worker::Worker for AgentBlockWorker {
452 fn id(&self) -> &crate::types::WorkerId {
453 &self.handler.worker_id
454 }
455 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
456 self.handler.cancel.clone()
457 }
458 async fn join(self: Box<Self>) -> Result<(), WorkerError> {
459 self.handler.await_completion().await
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn resolve_needed_mcp_servers_filters_by_tool_prefix() {
469 let tools = vec![
470 "mcp__semantic-scholar__search_papers".to_string(),
471 "mcp__semantic-scholar__get_paper".to_string(),
472 "Read".to_string(),
473 "mcp__outline__list_docs".to_string(),
474 "WebSearch".to_string(),
475 ];
476 let spec_servers = vec![
477 serde_json::json!({"name": "semantic-scholar", "command": "ss-mcp", "args": []}),
478 serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []}),
479 serde_json::json!({"name": "unused", "command": "nope", "args": []}),
480 ];
481 let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
482 assert_eq!(needed.len(), 2, "got: {needed:?}");
483 let names: Vec<&str> = needed
484 .iter()
485 .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
486 .collect();
487 assert!(names.contains(&"semantic-scholar"));
488 assert!(names.contains(&"outline"));
489 assert!(!names.contains(&"unused"), "unused server is filtered out");
490 }
491
492 #[test]
493 fn resolve_needed_mcp_servers_returns_empty_when_no_mcp_tools() {
494 let tools = vec!["Read".to_string(), "WebSearch".to_string()];
495 let spec_servers =
496 vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
497 let needed = resolve_needed_mcp_servers(&tools, &spec_servers);
498 assert!(
499 needed.is_empty(),
500 "no mcp__-prefixed tools → empty result, got: {needed:?}"
501 );
502 }
503
504 #[test]
505 fn build_inline_agent_invoker_embeds_mcp_servers_as_lua_literal() {
506 let servers =
507 vec![serde_json::json!({"name": "outline", "command": "outline-mcp", "args": []})];
508 let script = build_inline_agent_invoker(&servers);
509 match script {
510 ScriptSource::Inline { source, name } => {
511 assert!(name.ends_with(".lua"));
512 assert!(source.contains("require(\"agent\")"));
513 assert!(source.contains("mcp_servers = mcp_servers"));
514 assert!(source.contains("bus.emit(\"agent_result\""));
515 // Lua literal embed (= keys [\"name\"]=\"outline\" form)
516 assert!(source.contains("[\"name\"]=\"outline\""));
517 assert!(source.contains("[\"command\"]=\"outline-mcp\""));
518 assert!(source.contains("[\"args\"]={}"), "args empty array literal");
519 }
520 other => panic!("expected Inline, got: {other:?}"),
521 }
522 }
523
524 #[test]
525 fn build_inline_agent_invoker_with_empty_servers_still_valid() {
526 let script = build_inline_agent_invoker(&[]);
527 match script {
528 ScriptSource::Inline { source, .. } => {
529 assert!(source.contains("local mcp_servers = {}"));
530 }
531 other => panic!("expected Inline, got: {other:?}"),
532 }
533 }
534
535 #[test]
536 fn json_to_lua_literal_handles_primitives_and_nested() {
537 assert_eq!(json_to_lua_literal(&serde_json::json!(null)), "nil");
538 assert_eq!(json_to_lua_literal(&serde_json::json!(true)), "true");
539 assert_eq!(json_to_lua_literal(&serde_json::json!(42)), "42");
540 assert_eq!(json_to_lua_literal(&serde_json::json!("hi")), "\"hi\"");
541 assert_eq!(
542 json_to_lua_literal(&serde_json::json!(["a", "b"])),
543 "{\"a\", \"b\"}"
544 );
545 assert_eq!(
546 json_to_lua_literal(&serde_json::json!({"k": 1})),
547 "{[\"k\"]=1}"
548 );
549 }
550
551 #[test]
552 fn extract_prefers_content_then_response_then_whole() {
553 // (1) `content` takes priority (DefaultAgent invoker / agent.run return-value path).
554 let p = serde_json::json!({
555 "content": "Water boils at 100°C",
556 "messages": [{"role": "assistant"}],
557 "usage": {"input_tokens": 67, "output_tokens": 29},
558 "ok": true,
559 });
560 let (value, ok) = WorkerResultCaptor::extract(&p);
561 assert_eq!(value, serde_json::json!("Water boils at 100°C"));
562 assert!(ok);
563
564 // (2) No `content` → `response` (caller-script convention worker_result).
565 let p = serde_json::json!({ "ok": false, "response": {"patch": "..."} });
566 let (value, ok) = WorkerResultCaptor::extract(&p);
567 assert_eq!(value, serde_json::json!({"patch": "..."}));
568 assert!(!ok);
569
570 // (3) Neither present → the whole payload (custom shape).
571 let p = serde_json::json!({ "custom_field": 42 });
572 let (value, ok) = WorkerResultCaptor::extract(&p);
573 assert_eq!(value, serde_json::json!({"custom_field": 42}));
574 assert!(ok); // `ok` absent → defaults to true
575 }
576
577 #[tokio::test]
578 async fn captor_emits_worker_result_from_payload() {
579 let (tx, rx) = oneshot::channel();
580 let captor = WorkerResultCaptor {
581 tx: Mutex::new(Some(tx)),
582 };
583 let payload = serde_json::json!({ "ok": true, "response": "hello" });
584 let ack = captor
585 .call("worker_result".into(), "evt-1".into(), payload, Value::Null)
586 .await
587 .expect("handler ack");
588 assert_eq!(ack, Value::Null);
589 let wr = rx.await.expect("recv");
590 assert!(wr.ok);
591 assert_eq!(wr.value, serde_json::json!("hello"));
592 }
593
594 #[tokio::test]
595 async fn factory_builds_prompt_based_agent_when_script_path_absent() {
596 use crate::blueprint::compiler::SpawnerFactory;
597 use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
598
599 let factory = AgentBlockInProcessSpawnerFactory::new();
600 let ad = AgentDef {
601 name: "writer".into(),
602 kind: AgentKind::AgentBlock,
603 spec: serde_json::json!({}),
604 profile: Some(AgentProfile {
605 system_prompt: "You are writer.".into(),
606 ..Default::default()
607 }),
608 meta: None,
609 };
610 let _spawner = factory.build(&ad, None).expect("factory build");
611 // = ScriptSource::Inline path (self-hosted invoker, mcp_servers embed);
612 // the host_handler single sink captures every event kind.
613 }
614
615 #[tokio::test]
616 async fn factory_builds_script_based_agent_when_script_path_present() {
617 use crate::blueprint::compiler::SpawnerFactory;
618 use crate::blueprint::{AgentDef, AgentKind, AgentProfile};
619
620 let factory = AgentBlockInProcessSpawnerFactory::new();
621 let ad = AgentDef {
622 name: "patch-spawner".into(),
623 kind: AgentKind::AgentBlock,
624 spec: serde_json::json!({
625 "script_path": "assets/operator_scripts/blueprint_patch_spawner.lua",
626 "project_root": ".",
627 }),
628 profile: Some(AgentProfile {
629 system_prompt: "Patch generator.".into(),
630 ..Default::default()
631 }),
632 meta: None,
633 };
634 let _spawner = factory.build(&ad, None).expect("factory build");
635 // = ScriptSource::Path path; caller-provided script; host_handler single sink.
636 }
637}