agent_block_core/host.rs
1//! Host — the thin Rust shell that wires up Lua VM, Mesh, HTTP, and MCP.
2//!
3//! # Responsibilities
4//!
5//! 1. Spawn an mlua-isle `AsyncIsle` (dedicated Lua VM thread with coroutine support)
6//! 2. Optionally connect to agent-mesh relay
7//! 3. Initialize the MCP manager for stdio-based MCP server connections
8//! 4. Inject all Lua stdlib bridges (`mesh.*`, `http.*`, `sh.*`, `tool.*`, `log.*`, `mcp.*`)
9//! 5. Execute the user-provided Lua script via `coroutine_eval` (async-aware)
10//! 6. Graceful shutdown (Isle + MCP servers + mesh)
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16use tokio::sync::{mpsc, RwLock};
17
18use mlua_isle::{AsyncIsle, AsyncIsleDriver};
19use serde_json::{Map, Value};
20use tracing::{info, info_span, warn};
21
22use crate::bridge;
23use crate::bus::{Event, EventBus, Handler};
24use agent_block_mcp::McpManager;
25use agent_block_types::error::{BlockError, BlockResult};
26use tokio_util::sync::CancellationToken;
27
28/// Embedded Lua sources for the blocks that expose a tool surface.
29///
30/// Baked into the binary at compile time so `cargo install` works without any
31/// extra file distribution. The `require` name on the left is independent of
32/// the path on the right: `blocks/` is laid out by role (`agent/` runtime,
33/// `lib/`) while callers keep writing `require("agent")`.
34pub(crate) const EMBEDDED_BLOCKS: &[(&str, &str)] = &[
35 ("agent", include_str!("../blocks/agent/init.lua")),
36 ("coding", include_str!("../blocks/coding/init.lua")),
37];
38
39/// Embedded Lua support libraries — `require`-able like [`EMBEDDED_BLOCKS`]
40/// but not part of the block surface reported by [`inspect_tools`].
41///
42/// These are required by other modules rather than registered as tools:
43/// `llm_proto` is the provider-neutral LLM wire format, `session` persists a
44/// messages array through `std.kv`, and `lshape` is a schema validator.
45/// Listing them as tools would be misleading.
46pub(crate) const EMBEDDED_LIBS: &[(&str, &str)] = &[
47 ("session", include_str!("../blocks/lib/session/init.lua")),
48 (
49 "llm_proto",
50 include_str!("../blocks/lib/llm_proto/init.lua"),
51 ),
52 (
53 "llm_proto.openai",
54 include_str!("../blocks/lib/llm_proto/openai.lua"),
55 ),
56 (
57 "llm_proto.anthropic",
58 include_str!("../blocks/lib/llm_proto/anthropic.lua"),
59 ),
60 ("lshape", include_str!("../blocks/lib/lshape/init.lua")),
61 ("lshape.t", include_str!("../blocks/lib/lshape/t.lua")),
62 (
63 "lshape.check",
64 include_str!("../blocks/lib/lshape/check.lua"),
65 ),
66 (
67 "lshape.reflect",
68 include_str!("../blocks/lib/lshape/reflect.lua"),
69 ),
70 (
71 "lshape.luacats",
72 include_str!("../blocks/lib/lshape/luacats.lua"),
73 ),
74 (
75 "mcp_tools",
76 include_str!("../blocks/lib/mcp_tools/init.lua"),
77 ),
78 ("knl", include_str!("../blocks/lib/knl/init.lua")),
79 (
80 "knl_adapter",
81 include_str!("../blocks/lib/knl_adapter/init.lua"),
82 ),
83 ("policy", include_str!("../blocks/lib/policy/init.lua")),
84 (
85 "supervisor",
86 include_str!("../blocks/lib/supervisor/init.lua"),
87 ),
88 ("job", include_str!("../blocks/lib/job/init.lua")),
89];
90
91/// Embedded modules a filesystem copy may not replace.
92///
93/// The rule: **a module is sealed when replacing it would break something the
94/// host, not the caller, is answerable for.** That is the kernel and its
95/// declaration layer — `knl` (the Lua half of the kernel/shell split),
96/// `knl_adapter` (the ports it runs against) and `knl_types` (the lshape
97/// declaration of the Rust syscall surface, generated at start) — plus the
98/// vendored `lshape` those three are written in, sub-modules included. The
99/// kernel is one thing across Rust and Lua and the two halves are held
100/// together by declaration tests; a Lua-side replacement passes those tests
101/// while meaning something else. Everything else in [`EMBEDDED_BLOCKS`] and
102/// [`EMBEDDED_LIBS`] stays shadowable.
103///
104/// Checked by [`check_sealed_modules`] at host start, against the same
105/// filesystem roots [`lib_roots`] hands to the require registry. A
106/// sealed name found there fails the run; `AGENT_BLOCK_UNSEAL=1` downgrades
107/// the refusal to a `warn!` for work on the kernel itself. Reading a sealed
108/// module is not shadowing it: `require("embedded.knl")` always resolves to
109/// the embedded source (see [`EMBEDDED_ALIAS_PREFIX`]).
110///
111/// The README section "Embedded blocks: four layers" names this same set;
112/// `sealed_list_matches_the_readme` in this module's tests is the assertion
113/// that keeps the two from drifting.
114pub(crate) const SEALED: &[&str] = &[
115 "knl",
116 "knl_adapter",
117 "knl_types",
118 "lshape",
119 "lshape.t",
120 "lshape.check",
121 "lshape.reflect",
122 "lshape.luacats",
123];
124
125/// Prefix under which every embedded module is `require`-able a second time.
126///
127/// `require("embedded.agent")` is the embedded `agent` whatever
128/// `project_root/lib/agent/init.lua` says, which is what lets a project
129/// module shadow an embedded one and still delegate to the one it replaced:
130///
131/// ```lua
132/// local base = require("embedded.agent")
133/// local M = setmetatable({}, { __index = base })
134/// function M.run(opts) return base.run(opts) end
135/// return M
136/// ```
137///
138/// The alias evaluates the embedded source under its own name, so a module
139/// required both ways yields two tables. For the shadow-and-delegate case
140/// that is exactly one of each: `require("agent")` is the project's,
141/// `require("embedded.agent")` is the base it wraps.
142const EMBEDDED_ALIAS_PREFIX: &str = "embedded.";
143
144/// Embedded default agent invoker used by [`ScriptSource::DefaultAgent`].
145///
146/// Runs the StdPkg `agent` module with `_PROMPT` / `_CONTEXT` injected and
147/// emits the result on the EventBus. The emit kind is `"_"` — a neutral
148/// label with no SDK-side meaning. The result is intended to be received
149/// via [`BlockConfig::host_handler`] (the kind-agnostic single sink); the
150/// literal label is irrelevant to SDK consumers.
151const DEFAULT_AGENT_INVOKER: &str = r#"
152local agent = require("agent")
153local r = agent.run({
154 prompt = _PROMPT,
155 system = _CONTEXT,
156})
157bus.emit("_", r)
158"#;
159
160/// How the Lua script source for `run()` is supplied.
161///
162/// `Path` matches the CLI form (`agent-block -s <path>`), reading from
163/// the filesystem at start. `Inline` lets SDK consumers pass a script
164/// they hold in memory (compile-time `include_str!`, dynamically built
165/// string, etc.) without writing it to a tempfile. `DefaultAgent` uses
166/// an embedded invoker that runs the StdPkg `agent` module with the
167/// caller-supplied prompt/context and emits the result via
168/// `bus.emit("agent_result", ...)`.
169#[derive(Debug, Clone)]
170pub enum ScriptSource {
171 /// Read the script from a filesystem path at start.
172 Path(PathBuf),
173 /// Use the supplied source code directly.
174 Inline {
175 /// Lua source code.
176 source: String,
177 /// Display name used in tracing, error messages, and the Lua
178 /// `_SCRIPT_NAME` global (e.g. `"agent_invoker.lua"`).
179 name: String,
180 },
181 /// Use the embedded default agent invoker. `prompt` / `context`
182 /// are forwarded as `_PROMPT` / `_CONTEXT` Lua globals and the
183 /// agent result is emitted on the EventBus under a neutral label
184 /// (`"_"`). SDK consumers should pair this with
185 /// [`BlockConfig::host_handler`] (the kind-agnostic single sink)
186 /// and `auto_serve_bus = true`. The emit-kind is intentionally
187 /// meaningless; consumers that need string-keyed routing should
188 /// supply [`ScriptSource::Inline`] with their own invoker.
189 DefaultAgent,
190}
191
192/// How a string payload (prompt / system context) is supplied.
193///
194/// `Inline` is the literal string variant (CLI `--prompt` / `--context`).
195/// `File` reads the contents from disk at `run()` start (CLI
196/// `--prompt-file` / `--context-file`).
197#[derive(Debug, Clone)]
198pub enum PromptSource {
199 /// Literal string.
200 Inline(String),
201 /// Filesystem path; contents are read at `run()` start.
202 File(PathBuf),
203}
204
205/// How the Ed25519 mesh identity secret key is supplied.
206///
207/// `Inline` is a 64-hex literal. `Env` reads the named environment
208/// variable at `run()` start (CLI default uses
209/// `AGENT_BLOCK_MESH_SECRET_KEY`). Absence of any `SecretKeySource`
210/// (i.e. `BlockConfig.secret_key = None`) causes a random keypair to
211/// be generated, matching the prior behavior.
212#[derive(Debug, Clone)]
213pub enum SecretKeySource {
214 /// 64-character hex literal.
215 Inline(String),
216 /// Environment variable name to read at start.
217 Env(String),
218}
219
220/// Async handler invoked when the LLM (or a Lua call to
221/// `tool.call(name, ...)`) targets a Rust-implemented tool supplied via
222/// [`BlockConfig::host_tools`].
223///
224/// `input` arrives as a `serde_json::Value` (converted from Lua before
225/// the handler is invoked). The returned value is converted back to a
226/// Lua value and delivered to the caller. Errors are propagated as
227/// `LuaError::external` (visible inside the script) and as `BlockError`
228/// on the Rust side.
229#[async_trait::async_trait]
230pub trait ToolHandler: Send + Sync + 'static {
231 async fn call(&self, input: serde_json::Value) -> Result<serde_json::Value, BlockError>;
232}
233
234/// Declarative spec for a Rust-implemented tool injected into the Lua
235/// tool registry before the user script runs. The resulting entry is
236/// indistinguishable from a Lua-defined tool from the script's view:
237/// `tool.call("<name>", input)`, `agent.run({ ... })` tool dispatch,
238/// and `tool.schema()` enumeration all work uniformly.
239#[derive(Clone)]
240pub struct HostToolSpec {
241 /// Tool name. Becomes the routing key in `_TOOL_REGISTRY` and the
242 /// `name` field exposed by `tool.schema()` (Anthropic tool spec).
243 pub name: String,
244 /// Free-form description shown to the LLM. Becomes the
245 /// `description` field of the Anthropic tool spec.
246 pub description: String,
247 /// Input schema (Anthropic-compatible JSON Schema object).
248 pub input_schema: serde_json::Value,
249 /// Optional group label for [`agent.run`'s `tool_groups`] filter
250 /// and for [`BlockConfig::tool_policy`] (planned).
251 pub group: Option<String>,
252 /// Rust callback dispatched on every invocation.
253 pub handler: Arc<dyn ToolHandler>,
254}
255
256impl std::fmt::Debug for HostToolSpec {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("HostToolSpec")
259 .field("name", &self.name)
260 .field("description", &self.description)
261 .field("input_schema", &self.input_schema)
262 .field("group", &self.group)
263 .field("handler", &"<dyn ToolHandler>")
264 .finish()
265 }
266}
267
268/// Snapshot of a tool that a given [`BlockConfig`] will (statically)
269/// expose to the LLM. Produced by [`inspect_tools`] without running
270/// the script. MCP server tools are *not* included because they are
271/// only known after the MCP `initialize` handshake completes; callers
272/// that need that view should run the script and call `tool.schema()`
273/// from Lua.
274#[derive(Debug, Clone)]
275pub struct ToolMeta {
276 pub name: String,
277 pub description: String,
278 pub group: Option<String>,
279 pub source: ToolSource,
280}
281
282/// Origin of a tool listed by [`inspect_tools`].
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum ToolSource {
285 /// Supplied via [`BlockConfig::host_tools`] (Rust-implemented).
286 HostRust,
287 /// Embedded StdPkg block (`agent`) — discovered
288 /// statically from [`EMBEDDED_BLOCKS`]. Note: not every embedded
289 /// block exposes a registered tool; this entry simply records that
290 /// the module is available via `require(...)`.
291 EmbeddedBlock,
292}
293
294/// Inspect the tools a [`BlockConfig`] will expose to the LLM without
295/// actually running the script. Returns the merged list of
296/// `host_tools` (declared in the config) and embedded-block sources.
297///
298/// MCP server tools are deliberately omitted — they only become known
299/// after the MCP `initialize` handshake. Use `tool.schema()` from
300/// inside the running script for that view.
301pub fn inspect_tools(config: &BlockConfig) -> Vec<ToolMeta> {
302 let mut out = Vec::new();
303 for t in &config.host_tools {
304 out.push(ToolMeta {
305 name: t.name.clone(),
306 description: t.description.clone(),
307 group: t.group.clone(),
308 source: ToolSource::HostRust,
309 });
310 }
311 for (name, _src) in EMBEDDED_BLOCKS {
312 out.push(ToolMeta {
313 name: (*name).to_string(),
314 description: format!("Embedded StdPkg block (require(\"{name}\"))"),
315 group: None,
316 source: ToolSource::EmbeddedBlock,
317 });
318 }
319 out
320}
321
322/// The directory a project keeps its own copies of agent-block's own files in:
323/// `blocks/` and `lib/` under `<project>/.agent-block/`.
324///
325/// Beside the project's `.gitignore`, dotted like the rest of a repository's
326/// tooling, and the first tier of both lookups — which is what makes its `lib/`
327/// the place [`crate::embedded`]'s consumer (`agent-block vendor`) writes to. A
328/// project that has never vendored anything never has the directory, and the
329/// tier costs it nothing: only existing directories are searched.
330pub const PROJECT_DIR: &str = ".agent-block";
331
332/// Filesystem roots that `require` searches for libraries, highest priority
333/// first. Only existing directories are returned.
334///
335/// 1. `project_root/.agent-block/lib/` — the project's vendored copies
336/// 2. `project_root/lib/` — the project's own modules
337/// 3. `$AGENT_BLOCK_HOME/lib/` (default `~/.agent-block/lib/`) — the user's
338/// modules, shared by every project on the machine
339///
340/// The order is not arbitrary at either end. [`PROJECT_DIR`] is first because
341/// it is the project's own — versioned with it, visible in its diff — and
342/// because what lands there is a copy of an embedded module that the project
343/// means to have taken over (`agent-block vendor`); a tier below `lib/` would
344/// make vendoring a thing that silently does nothing when the project already
345/// has a hand-written module of that name. The user's home is last because a
346/// file under `~/.agent-block/` changes **every project on the machine**: it is
347/// the right place for what every project should share and the wrong place to
348/// make one project behave, and being the last tier is what keeps it from
349/// quietly answering for a project that never asked it to.
350///
351/// The embedded sources come after these, and the script's own directory
352/// before them; both are wired in [`build_isle_init`].
353///
354/// `lib/` and `blocks/` are two directories on purpose. `blocks/` holds
355/// entry points — scripts the CLI `--block` flag and `agent-block mcp` run
356/// by name — and is never on the require path, so a helper dropped beside
357/// a block does not become a callable block, and a block cannot be
358/// `require`d as if it were a module. A module that starts as a helper
359/// inside a project's `lib/` moves to the user's `lib/` and then upstream
360/// (into [`EMBEDDED_LIBS`]) without renaming: each tier resolves the same
361/// name, and the first one that has it wins.
362///
363/// `AGENT_BLOCK_HOME` being unresolvable (no `HOME`) only drops the user
364/// tier; it is not an error here because the kv / sql / ts bridges report
365/// that condition already, on the paths they actually need.
366pub fn lib_roots(project_root: &Path) -> Vec<PathBuf> {
367 let mut out = Vec::new();
368
369 let vendored_lib = project_root.join(PROJECT_DIR).join("lib");
370 if vendored_lib.is_dir() {
371 out.push(vendored_lib);
372 }
373
374 let project_lib = project_root.join("lib");
375 if project_lib.is_dir() {
376 out.push(project_lib);
377 }
378
379 if let Ok(home) = crate::bridge::config::base_dir() {
380 let user_lib = home.join("lib");
381 if user_lib.is_dir() {
382 out.push(user_lib);
383 }
384 }
385
386 out
387}
388
389/// Filesystem roots that hold entry points — blocks run by name — highest
390/// priority first. Only existing directories are returned.
391///
392/// 1. `project_root/.agent-block/blocks/` — entry points kept with the
393/// project's other agent-block files
394/// 2. `project_root/blocks/`
395/// 3. `$AGENT_BLOCK_HOME/blocks/` (default `~/.agent-block/blocks/`)
396///
397/// First and last for the reasons [`lib_roots`] gives: [`PROJECT_DIR`] is the
398/// project's own directory, beside its `.gitignore`. Nothing writes tier 1 for
399/// a caller — `agent-block vendor` only ever writes modules, because every
400/// embedded entry is reached by `require` — so a file here is a script the
401/// project put there itself. The user's home is last because a
402/// file under `~/.agent-block/` answers for every project on the machine and so
403/// is the shared fallback rather than the place to specialise one project.
404///
405/// The tiers mirror [`lib_roots`] so that a script and the modules it needs
406/// live side by side at each level: `blocks/summarize.lua` next to
407/// `lib/summarize_util.lua`, in the project or in the user's home. A block is
408/// `<name>.lua` or `<name>/init.lua` directly under one of these roots; what
409/// is in them is never `require`d, and what is in `lib/` is never run. The
410/// CLI (`--block <name>`) and `agent-block mcp` both resolve names through
411/// these roots, so the same file is the same block on both surfaces.
412pub fn block_roots(project_root: &Path) -> Vec<PathBuf> {
413 let mut out = Vec::new();
414
415 let vendored_blocks = project_root.join(PROJECT_DIR).join("blocks");
416 if vendored_blocks.is_dir() {
417 out.push(vendored_blocks);
418 }
419
420 let project_blocks = project_root.join("blocks");
421 if project_blocks.is_dir() {
422 out.push(project_blocks);
423 }
424
425 if let Ok(home) = crate::bridge::config::base_dir() {
426 let user_blocks = home.join("blocks");
427 if user_blocks.is_dir() {
428 out.push(user_blocks);
429 }
430 }
431
432 out
433}
434
435/// Render `roots` as a semicolon-terminated `package.path` prefix, trying
436/// `<name>.lua` before `<name>/init.lua` under each root — the same order
437/// `mlua_pkg::FsResolver` uses, so the two resolution paths agree.
438fn package_path_prefix(roots: &[PathBuf]) -> String {
439 let mut out = String::new();
440 for root in roots {
441 let r = root.to_string_lossy();
442 out.push_str(&format!("{r}/?.lua;{r}/?/init.lua;"));
443 }
444 out
445}
446
447/// The two filesystem paths that would resolve `name` under `root`.
448///
449/// Mirrors `mlua_pkg::resolvers::FsResolver`: the module separator becomes a
450/// path separator, then `{name}.lua` is tried before `{name}/init.lua`.
451fn require_candidates(root: &Path, name: &str) -> [PathBuf; 2] {
452 let relative = name.replace('.', "/");
453 [
454 root.join(format!("{relative}.lua")),
455 root.join(format!("{relative}/init.lua")),
456 ]
457}
458
459/// Refuse the run when a filesystem root would shadow a [sealed](SEALED)
460/// module.
461///
462/// Runs once at host start, before any Isle exists, over exactly the roots the
463/// require registry is about to search — so a `lib/knl/init.lua` stops the
464/// run rather than quietly becoming the kernel. `AGENT_BLOCK_UNSEAL=1` turns
465/// the refusal into a `warn!`; it exists for work on the kernel itself and is
466/// documented as such.
467fn check_sealed_modules(roots: &[PathBuf]) -> BlockResult<()> {
468 let unsealed = std::env::var("AGENT_BLOCK_UNSEAL").is_ok_and(|v| v == "1");
469
470 for root in roots {
471 for name in SEALED {
472 for path in require_candidates(root, name) {
473 if !path.is_file() {
474 continue;
475 }
476 if unsealed {
477 warn!(
478 module = %name,
479 path = %path.display(),
480 "AGENT_BLOCK_UNSEAL=1: a sealed module is being replaced by a filesystem copy"
481 );
482 continue;
483 }
484 return Err(BlockError::Runtime(sealed_refusal(name, &path)));
485 }
486 }
487 }
488
489 Ok(())
490}
491
492/// The refusal text: which module, which file, and why this one is not
493/// yours to replace.
494fn sealed_refusal(name: &str, path: &Path) -> String {
495 format!(
496 "sealed module `{name}` cannot be shadowed: {} would replace the embedded one. \
497 The kernel is one thing across Rust and Lua — `knl` / `knl_adapter` / `knl_types` \
498 and the `lshape` they are declared in are held together by declaration tests, and a \
499 Lua-side replacement passes those tests while meaning something else. Change it \
500 upstream. To read the embedded module (wrapping it is fine, replacing it is not), \
501 `require(\"{EMBEDDED_ALIAS_PREFIX}{name}\")`. Set AGENT_BLOCK_UNSEAL=1 to downgrade \
502 this refusal to a warning — for work on the kernel itself, not for shipping.",
503 path.display()
504 )
505}
506
507/// Full configuration for a single [`run`] execution.
508///
509/// # Construction
510///
511/// Prefer [`BlockConfig::builder`] over struct-literal construction. The
512/// struct is `#[non_exhaustive]`, so crates outside `agent-block-core`
513/// cannot build it with a `BlockConfig { .. }` literal; the builder is the
514/// supported, forward-compatible path. New fields are added with sensible
515/// defaults, so existing builder call sites keep compiling when the config
516/// surface grows.
517///
518/// All fields remain `pub` for reading (`config.project_root`, etc.); only
519/// the literal-construction form is gated by `#[non_exhaustive]`.
520///
521/// ```no_run
522/// use agent_block_core::BlockConfig;
523/// use agent_block_core::host::ScriptSource;
524/// use std::path::PathBuf;
525///
526/// let config = BlockConfig::builder(
527/// ScriptSource::Path(PathBuf::from("agent.lua")),
528/// PathBuf::from("."),
529/// )
530/// .auto_serve_bus(true)
531/// .build();
532/// ```
533#[non_exhaustive]
534pub struct BlockConfig {
535 /// Lua script to execute. See [`ScriptSource`] for the supported
536 /// shapes (filesystem path / inline source / embedded default
537 /// agent invoker).
538 pub script: ScriptSource,
539 pub project_root: PathBuf,
540 pub relay_url: Option<String>,
541 /// Ed25519 secret key for mesh identity. See [`SecretKeySource`]
542 /// for the supported shapes (inline 64-hex / environment variable).
543 /// `None` generates a random keypair. Required to talk to
544 /// registry/ACL-gated hosted meshes.
545 pub secret_key: Option<SecretKeySource>,
546 /// Per-RPC timeout for every MCP round-trip (connect / list / call).
547 /// Defaults to [`agent_block_mcp::DEFAULT_RPC_TIMEOUT`].
548 pub mcp_rpc_timeout: Duration,
549 /// Prompt payload injected as `_PROMPT` Lua global. See
550 /// [`PromptSource`] for the supported shapes. `None` leaves the
551 /// global unset.
552 pub prompt: Option<PromptSource>,
553 /// Context payload injected as `_CONTEXT` Lua global (typically
554 /// the system prompt). Same shape rules as [`Self::prompt`].
555 pub context: Option<PromptSource>,
556 /// Host-side Rust handlers pre-installed on the EventBus before the user
557 /// script starts. Each entry registers `handler` against `kind` via
558 /// [`EventBus::on`], so a script-side `bus.emit(kind, payload)` is
559 /// captured by the Rust handler rather than dispatched to a Lua function.
560 ///
561 /// Intended for SDK consumers that embed `agent-block-core` and need to
562 /// receive script output programmatically (e.g. a Spawner adapter that
563 /// turns LLM script output into a typed `WorkerResult`). Lua-side
564 /// `bus.on(kind, fn)` registrations layered on top of the handler Isle
565 /// are still possible, but the EventBus dispatches a single handler per
566 /// `kind` (last-write-wins), so host-side and Lua-side registrations on
567 /// the same `kind` collide; choose one side per routing key.
568 ///
569 /// Defaults to an empty map (no host handlers).
570 pub host_handlers: HashMap<String, Arc<dyn Handler>>,
571 /// Single host-side Rust handler that catches every event regardless
572 /// of `kind`. Internally registered via [`EventBus::on_any`], so it
573 /// acts as a fallback when no entry in [`Self::host_handlers`]
574 /// matches the incoming `kind`.
575 ///
576 /// This is the SDK-embed 1-shot sink: SDK consumers do not need to
577 /// invent or coordinate a string `kind` between the Lua script and
578 /// their Rust code. The agent invoker's emit-kind is irrelevant —
579 /// the handler receives every event.
580 ///
581 /// Use this when you want a single Rust handler to receive results
582 /// (typical embedded use). Use [`Self::host_handlers`] instead when
583 /// you actually need string-keyed routing (multi-source / multi-
584 /// handler dispatch). The two may coexist: kind-specific handlers
585 /// in `host_handlers` take precedence, and this single handler is
586 /// the fallback for unmatched kinds.
587 ///
588 /// Defaults to `None`.
589 pub host_handler: Option<Arc<dyn Handler>>,
590 /// Rust-implemented tools injected into the Lua tool registry
591 /// before the user script runs. Each entry becomes
592 /// indistinguishable from a Lua-defined tool: it is discoverable
593 /// via `tool.list()` / `tool.schema()`, dispatchable via
594 /// `tool.call(name, input)`, and visible to `agent.run`'s LLM
595 /// function-calling.
596 ///
597 /// SDK consumers can use this to expose Rust capabilities
598 /// (database lookups, business logic, etc.) to the LLM without
599 /// writing any Lua. See [`HostToolSpec`] and [`ToolHandler`].
600 ///
601 /// Defaults to an empty list.
602 pub host_tools: Vec<HostToolSpec>,
603 /// Optional custom `reqwest::Client` for the `http.*` Lua bridge
604 /// and any other in-process HTTP traffic. SDK consumers can wire
605 /// in their own TLS roots, proxy, default headers, connection
606 /// pool tuning, etc.
607 ///
608 /// `None` falls back to `reqwest::Client::new()` with default
609 /// settings (legacy behavior).
610 pub http_client: Option<reqwest::Client>,
611 /// Override path for the `std.sql` SQLite database file. `None`
612 /// reads the `AGENT_BLOCK_SQL_PATH` env var (CLI default), or
613 /// falls back to `{base_dir}/db.sqlite`. Pass `Some(":memory:")`
614 /// for an in-memory DB (useful for tests / isolation).
615 pub sql_path: Option<PathBuf>,
616 /// Override path for the `std.kv` SQLite database file. Same
617 /// semantics as [`Self::sql_path`].
618 pub kv_path: Option<PathBuf>,
619 /// Override path for the `std.ts` SQLite database file. Same
620 /// semantics as [`Self::sql_path`].
621 pub ts_path: Option<PathBuf>,
622 /// What this run is, as labels every `knl` session opened here is
623 /// recorded with — the `meta` of its `session_opened`.
624 ///
625 /// For a caller that runs the same block over and over and needs to tell
626 /// the runs apart afterwards. The alternative it replaces is giving each
627 /// run a database of its own (`AGENT_BLOCK_KNL_PATH` per run), which
628 /// answers the same question by breaking the one above it: a project's
629 /// log stops being one stream to read.
630 ///
631 /// Shallow by the same rule `meta` is everywhere — a string, a number or
632 /// a flag — and a nested value is refused when the session opens. A
633 /// script's own `knl.open{ meta = … }` wins on a key both name.
634 ///
635 /// Defaults to empty: a script nobody is running on a schedule is not a
636 /// run of anything.
637 pub session_labels: Map<String, Value>,
638 /// Extra Lua globals injected into both the main Isle and the
639 /// handler Isle before the user script runs. Each entry
640 /// `(name, value)` results in `_G[name] = json_to_lua(value)`.
641 ///
642 /// Use this to parameterize an inline script from Rust without
643 /// baking the values into the Lua source (`_USER_ID`,
644 /// `_TENANT`, `_FEATURE_FLAGS`, etc.). Keys must be valid Lua
645 /// identifiers; values are any `serde_json::Value`.
646 ///
647 /// `_PROMPT`, `_CONTEXT`, and `_SCRIPT_NAME` are reserved
648 /// (managed by other `BlockConfig` fields); colliding with them
649 /// silently overrides those defaults — use with care.
650 pub extra_globals: HashMap<String, serde_json::Value>,
651 /// When `true`, the EventBus dispatcher loop is driven in the background
652 /// for the duration of the script and shut down gracefully after the
653 /// script completes. Required for SDK-embed callers that supply
654 /// [`Self::host_handlers`] and need `bus.emit(kind, payload)` events
655 /// emitted from the script to actually reach those handlers without
656 /// requiring the script to call `bus.serve()` (which blocks on
657 /// SIGTERM / Ctrl+C and never returns under programmatic embedding).
658 ///
659 /// After the script finishes, the dispatcher is given a grace window
660 /// (`AGENT_BLOCK_TASK_GRACE_MS`, default 1000ms) to drain queued events
661 /// and finish any in-flight handler, then is cancelled.
662 ///
663 /// Mutually exclusive with Lua-side `bus.serve()`: enabling this flag
664 /// takes ownership of the EventBus before the script runs, so a script
665 /// that calls `bus.on(...)` followed by `bus.serve()` will error
666 /// ("bus.serve() has already taken ownership"). Use this flag when the
667 /// script's sole purpose is to push events to host handlers.
668 ///
669 /// Defaults to `false` (legacy behavior: dispatcher only runs when the
670 /// script calls `bus.serve()`).
671 pub auto_serve_bus: bool,
672 /// Optional caller-supplied cancellation token. When cancelled, the
673 /// in-flight script is interrupted via the Isle's debug-hook cancel
674 /// path, the auto-serve dispatcher (if any) is shut down, and `run()`
675 /// returns `Err(BlockError::Cancelled)`.
676 ///
677 /// Intended for SDK consumers that spawn `run()` as a tokio task and
678 /// need an out-of-band abort signal (timeouts, parent-task cancellation
679 /// propagation, user-driven stop). The token is observed across the
680 /// `coroutine_eval` await; once cancellation propagates, the shutdown
681 /// sequence (MCP disconnect, Isle drivers, auto-serve dispatcher)
682 /// still runs so file descriptors and remote handles are released.
683 ///
684 /// Defaults to `None` (legacy behavior: `run()` only completes when
685 /// the script returns naturally).
686 pub shutdown_token: Option<CancellationToken>,
687 /// An HTTP listener that feeds the bus ([`crate::bus::http_source`]):
688 /// every request becomes an event the script handles with `bus.on`, and
689 /// the handler's answer is the response. Started before the script
690 /// runs, stopped after it returns. Defaults to `None`.
691 pub http_source: Option<crate::bus::http_source::HttpSourceConfig>,
692}
693
694impl BlockConfig {
695 /// Start building a [`BlockConfig`] with the two semantically required
696 /// inputs supplied up front.
697 ///
698 /// `script` selects what Lua source to execute — there is no meaningful
699 /// default, since a run has nothing to do without a script. `project_root`
700 /// anchors `.env` loading, inline-script directory resolution, and the
701 /// default working directory handed to spawned MCP servers.
702 ///
703 /// Every other field starts at the default documented on the matching
704 /// [`BlockConfig`] field and is overridden through the chainable
705 /// [`BlockConfigBuilder`] setters. This is the recommended construction
706 /// path for SDK embedders: `BlockConfig` is `#[non_exhaustive]`, so
707 /// struct-literal construction is unavailable to downstream crates and new
708 /// fields can be added without breaking existing builder call sites.
709 ///
710 /// ```no_run
711 /// use agent_block_core::BlockConfig;
712 /// use agent_block_core::host::ScriptSource;
713 /// use std::path::PathBuf;
714 ///
715 /// let config = BlockConfig::builder(
716 /// ScriptSource::Path(PathBuf::from("agent.lua")),
717 /// PathBuf::from("."),
718 /// )
719 /// .auto_serve_bus(true)
720 /// .build();
721 /// ```
722 pub fn builder(script: ScriptSource, project_root: PathBuf) -> BlockConfigBuilder {
723 BlockConfigBuilder::new(script, project_root)
724 }
725}
726
727/// Chainable builder for [`BlockConfig`], created via
728/// [`BlockConfig::builder`].
729///
730/// Each setter returns `self` for fluent chaining. Setters for `Option<_>`
731/// fields take the inner value and wrap it in `Some` internally, so callers
732/// pass e.g. `.prompt(PromptSource::Inline(..))` rather than an `Option`.
733/// Fields left untouched keep the defaults documented on the corresponding
734/// [`BlockConfig`] field.
735///
736/// Because [`BlockConfig`] is `#[non_exhaustive]`, this builder is the only
737/// supported way for crates outside `agent-block-core` to construct one, and
738/// it stays source-compatible as new config fields are introduced.
739pub struct BlockConfigBuilder {
740 script: ScriptSource,
741 project_root: PathBuf,
742 relay_url: Option<String>,
743 secret_key: Option<SecretKeySource>,
744 mcp_rpc_timeout: Duration,
745 prompt: Option<PromptSource>,
746 context: Option<PromptSource>,
747 host_handlers: HashMap<String, Arc<dyn Handler>>,
748 host_handler: Option<Arc<dyn Handler>>,
749 host_tools: Vec<HostToolSpec>,
750 http_client: Option<reqwest::Client>,
751 sql_path: Option<PathBuf>,
752 kv_path: Option<PathBuf>,
753 ts_path: Option<PathBuf>,
754 session_labels: Map<String, Value>,
755 extra_globals: HashMap<String, serde_json::Value>,
756 auto_serve_bus: bool,
757 shutdown_token: Option<CancellationToken>,
758 http_source: Option<crate::bus::http_source::HttpSourceConfig>,
759}
760
761impl BlockConfigBuilder {
762 fn new(script: ScriptSource, project_root: PathBuf) -> Self {
763 Self {
764 script,
765 project_root,
766 relay_url: None,
767 secret_key: None,
768 mcp_rpc_timeout: agent_block_mcp::DEFAULT_RPC_TIMEOUT,
769 prompt: None,
770 context: None,
771 host_handlers: HashMap::new(),
772 host_handler: None,
773 host_tools: Vec::new(),
774 http_client: None,
775 sql_path: None,
776 kv_path: None,
777 ts_path: None,
778 session_labels: Map::new(),
779 extra_globals: HashMap::new(),
780 auto_serve_bus: false,
781 shutdown_token: None,
782 http_source: None,
783 }
784 }
785
786 /// Override the Lua script to execute (`BlockConfig::script`).
787 pub fn script(mut self, script: ScriptSource) -> Self {
788 self.script = script;
789 self
790 }
791
792 /// Override the project root (`BlockConfig::project_root`).
793 pub fn project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
794 self.project_root = project_root.into();
795 self
796 }
797
798 /// Set the mesh relay URL (`BlockConfig::relay_url`). Defaults to `None`
799 /// (mesh disabled).
800 pub fn relay_url(mut self, relay_url: impl Into<String>) -> Self {
801 self.relay_url = Some(relay_url.into());
802 self
803 }
804
805 /// Set the mesh identity secret key source (`BlockConfig::secret_key`).
806 /// Defaults to `None` (random keypair).
807 pub fn secret_key(mut self, secret_key: SecretKeySource) -> Self {
808 self.secret_key = Some(secret_key);
809 self
810 }
811
812 /// Override the per-RPC MCP timeout (`BlockConfig::mcp_rpc_timeout`).
813 /// Defaults to [`agent_block_mcp::DEFAULT_RPC_TIMEOUT`].
814 pub fn mcp_rpc_timeout(mut self, mcp_rpc_timeout: Duration) -> Self {
815 self.mcp_rpc_timeout = mcp_rpc_timeout;
816 self
817 }
818
819 /// Set the prompt payload injected as `_PROMPT` (`BlockConfig::prompt`).
820 /// Defaults to `None`.
821 pub fn prompt(mut self, prompt: PromptSource) -> Self {
822 self.prompt = Some(prompt);
823 self
824 }
825
826 /// Set the context payload injected as `_CONTEXT`
827 /// (`BlockConfig::context`). Defaults to `None`.
828 pub fn context(mut self, context: PromptSource) -> Self {
829 self.context = Some(context);
830 self
831 }
832
833 /// Set the kind-keyed host-side handlers (`BlockConfig::host_handlers`).
834 /// Defaults to an empty map.
835 pub fn host_handlers(mut self, host_handlers: HashMap<String, Arc<dyn Handler>>) -> Self {
836 self.host_handlers = host_handlers;
837 self
838 }
839
840 /// Set the kind-agnostic fallback host handler
841 /// (`BlockConfig::host_handler`). Defaults to `None`.
842 pub fn host_handler(mut self, host_handler: Arc<dyn Handler>) -> Self {
843 self.host_handler = Some(host_handler);
844 self
845 }
846
847 /// Set the Rust-implemented tools injected into the Lua registry
848 /// (`BlockConfig::host_tools`). Defaults to an empty list.
849 pub fn host_tools(mut self, host_tools: Vec<HostToolSpec>) -> Self {
850 self.host_tools = host_tools;
851 self
852 }
853
854 /// Set a custom `reqwest::Client` for the `http.*` bridge
855 /// (`BlockConfig::http_client`). Defaults to `None`.
856 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
857 self.http_client = Some(http_client);
858 self
859 }
860
861 /// Override the `std.sql` database path (`BlockConfig::sql_path`).
862 /// Defaults to `None`.
863 pub fn sql_path(mut self, sql_path: impl Into<PathBuf>) -> Self {
864 self.sql_path = Some(sql_path.into());
865 self
866 }
867
868 /// Override the `std.kv` database path (`BlockConfig::kv_path`).
869 /// Defaults to `None`.
870 pub fn kv_path(mut self, kv_path: impl Into<PathBuf>) -> Self {
871 self.kv_path = Some(kv_path.into());
872 self
873 }
874
875 /// Override the `std.ts` database path (`BlockConfig::ts_path`).
876 /// Defaults to `None`.
877 pub fn ts_path(mut self, ts_path: impl Into<PathBuf>) -> Self {
878 self.ts_path = Some(ts_path.into());
879 self
880 }
881
882 /// Label every `knl` session this run opens
883 /// ([`BlockConfig::session_labels`]).
884 ///
885 /// What a caller running the same block repeatedly uses to tell the runs
886 /// apart in one log. Called more than once, the labels accumulate; the
887 /// same key twice keeps the last.
888 pub fn session_label(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
889 self.session_labels.insert(key.into(), value.into());
890 self
891 }
892
893 /// Set the extra Lua globals injected before the script runs
894 /// (`BlockConfig::extra_globals`). Defaults to an empty map.
895 pub fn extra_globals(mut self, extra_globals: HashMap<String, serde_json::Value>) -> Self {
896 self.extra_globals = extra_globals;
897 self
898 }
899
900 /// Enable or disable the background EventBus dispatcher
901 /// (`BlockConfig::auto_serve_bus`). Defaults to `false`.
902 pub fn auto_serve_bus(mut self, auto_serve_bus: bool) -> Self {
903 self.auto_serve_bus = auto_serve_bus;
904 self
905 }
906
907 /// Set the caller-supplied cancellation token
908 /// (`BlockConfig::shutdown_token`). Defaults to `None`.
909 pub fn shutdown_token(mut self, shutdown_token: CancellationToken) -> Self {
910 self.shutdown_token = Some(shutdown_token);
911 self
912 }
913
914 /// Start an HTTP listener that feeds the bus
915 /// (`BlockConfig::http_source`). Defaults to `None`.
916 pub fn http_source(mut self, http_source: crate::bus::http_source::HttpSourceConfig) -> Self {
917 self.http_source = Some(http_source);
918 self
919 }
920
921 /// Finalize the builder into a [`BlockConfig`].
922 pub fn build(self) -> BlockConfig {
923 BlockConfig {
924 script: self.script,
925 project_root: self.project_root,
926 relay_url: self.relay_url,
927 secret_key: self.secret_key,
928 mcp_rpc_timeout: self.mcp_rpc_timeout,
929 prompt: self.prompt,
930 context: self.context,
931 host_handlers: self.host_handlers,
932 host_handler: self.host_handler,
933 host_tools: self.host_tools,
934 http_client: self.http_client,
935 sql_path: self.sql_path,
936 kv_path: self.kv_path,
937 ts_path: self.ts_path,
938 session_labels: self.session_labels,
939 extra_globals: self.extra_globals,
940 auto_serve_bus: self.auto_serve_bus,
941 shutdown_token: self.shutdown_token,
942 http_source: self.http_source,
943 }
944 }
945}
946
947/// A host-owned SQLite connection, in the shape `mlua-batteries-sqlite` takes.
948///
949/// The pair travels together everywhere: the mutex is what a statement locks
950/// (inside the blocking closure, never across an `.await`), and the interrupt
951/// handle is how a cancelled task or an expired query timeout gets the
952/// blocking thread to return and release it. Cloning shares one connection —
953/// there is exactly one per database.
954#[cfg(feature = "sqlite")]
955#[derive(Clone)]
956pub struct SqliteConn {
957 /// The connection itself. Locked inside `spawn_blocking`, so the VM
958 /// thread never holds the guard.
959 pub conn: Arc<Mutex<rusqlite::Connection>>,
960 /// `sqlite3_interrupt` for the statement currently running on `conn`.
961 pub interrupt: Arc<rusqlite::InterruptHandle>,
962}
963
964/// Shared context passed into Lua bridge functions.
965#[derive(Clone)]
966pub struct HostContext {
967 pub project_root: PathBuf,
968 /// Connected mesh agent (present only when the `mesh` feature is enabled
969 /// and a relay URL was supplied).
970 #[cfg(feature = "mesh")]
971 pub mesh_agent: Option<Arc<agent_mesh_sdk::MeshAgent>>,
972 pub mcp_manager: Arc<RwLock<McpManager>>,
973 /// Shared async HTTP client for `http.*` bridge.
974 pub http_client: reqwest::Client,
975 /// The connection behind the `sql.*` bridge (user tables).
976 ///
977 /// Opened by the host and handed to `mlua-batteries-sqlite`, which runs
978 /// every statement inside `tokio::task::spawn_blocking` and takes the
979 /// mutex *there*, not on the VM thread — so no lock guard and no blocking
980 /// call crosses an `.await`, and the Lua VM yields while SQLite works.
981 #[cfg(feature = "sqlite")]
982 pub sql_conn: SqliteConn,
983 /// The connection behind the `kv.*` bridge (`__kv` table only).
984 ///
985 /// A separate database from `sql_conn`, so KV scratch state and user SQL
986 /// data do not share WAL, page cache, or backup lifecycle.
987 #[cfg(feature = "sqlite")]
988 pub kv_conn: SqliteConn,
989 /// Handle to the SQLite connection thread behind the `ts.*` bridge (TSDB —
990 /// time-series table).
991 ///
992 /// A third database, on a file of its own, so the TSDB's WAL shares
993 /// neither page cache nor backup lifecycle with kv/sql. Unlike the two
994 /// beside it, this connection is not shared but confined: it lives on that
995 /// thread, and `std.ts` sends statements to it and awaits them. Different
996 /// route, same rule — the Lua VM never waits on SQLite.
997 #[cfg(feature = "sqlite")]
998 pub ts_isle: rusqlite_isle::AsyncIsle,
999 /// Async handle to the main Isle Lua VM that runs the user script via
1000 /// `coroutine_eval`. After Subtask 2, `bridge::bus` no longer dispatches
1001 /// handlers against this Isle; handlers live on `handler_isle` instead.
1002 /// The field is retained because bridge code still keyed to the main
1003 /// Isle (future `coroutine_call` back-edges, introspection APIs) may
1004 /// need it, and removing it would force another HostContext reshape.
1005 #[allow(dead_code)]
1006 pub isle: Arc<AsyncIsle>,
1007 /// Dedicated Isle for EventBus handler execution. Lua handlers
1008 /// registered via `bus.on` / `bus.on_any` run here so that CPU-bound
1009 /// handler code does not occupy the main Isle's LocalSet and block
1010 /// grace timers / shutdown wakers on the main VM side.
1011 ///
1012 /// Used by `bridge::bus` to forward handler bytecode
1013 /// (`Function::dump(true)` → `handler_isle.exec(...)`) and by
1014 /// [`LuaHandler::call`](crate::bridge::bus) to dispatch via
1015 /// `coroutine_call("__bus_dispatch", ...)`.
1016 pub handler_isle: Arc<AsyncIsle>,
1017 /// Ingress sender for the EventBus. Adapters (mesh / webhook / …)
1018 /// clone this and push `Event`s. The mesh adapter captures its own clone
1019 /// at `MeshAgent::connect` time, so nothing reads the field itself today —
1020 /// kept `pub` so a further adapter can be wired without reopening this.
1021 #[allow(dead_code)]
1022 pub bus_tx: mpsc::Sender<Event>,
1023 /// Mutex-wrapped `Option<EventBus>` so `bus.on` / `bus.on_any` can lock
1024 /// briefly from sync Lua context, and `bus.serve` can `Option::take`
1025 /// ownership before entering the long-lived `run()` await (avoiding the
1026 /// await-holding-lock anti-pattern on a `std::sync::Mutex`).
1027 pub event_bus: Arc<Mutex<Option<EventBus>>>,
1028 /// Pre-edit file contents captured by `std.fs.edit`, consumed by
1029 /// `std.fs.rollback`. One level per path — enough to discard the last
1030 /// edit, which is what a build-and-fix loop needs when it decides an
1031 /// iteration made things worse.
1032 pub fs_snapshots: crate::bridge::fs::SnapshotStore,
1033 /// The event logs every `knl` session's stream lives in.
1034 ///
1035 /// A log is a database — one writer thread, read-only connections beside
1036 /// it — and it is opened once per file per process, so two sessions on one
1037 /// file are two streams of one log. A session holds a handle on the log
1038 /// rather than the log itself, which is what lets the drop backstop work:
1039 /// a handle nobody closed submits its `session_closed` from `Drop`,
1040 /// without waiting, and the log is still there to run it because its
1041 /// lifetime is the host's rather than the session's.
1042 ///
1043 /// Cloneable and shared, like the isle handles beside it; the run loop
1044 /// drains it once, in [`shutdown`], after the Lua VM is gone.
1045 pub knl_logs: crate::knl::Logs,
1046 /// The database a `knl` session opened without a `store` lands in.
1047 ///
1048 /// `{base_dir}/projects/<slug>/knl.sqlite`, resolved by
1049 /// [`crate::bridge::config::knl_path`] from the project root above, or
1050 /// whatever `AGENT_BLOCK_KNL_PATH` names. The host owns it for the same
1051 /// reason it owns the `sql` / `kv` / `ts` files: where a script's state
1052 /// goes is the host's answer, not the script's.
1053 ///
1054 /// One file per project, so every default session is a stream in it —
1055 /// which is what lets a tree opened from a default parent exist at all
1056 /// (a child is opened on its parent's database, and the in-memory one
1057 /// locks per table under its shared cache). `store = "mem"` remains the
1058 /// explicit way to ask for the process-local database instead.
1059 ///
1060 /// The directory is created at start, beside the other three; the file
1061 /// itself is SQLite's to create, on the first session that needs it.
1062 pub knl_store: PathBuf,
1063 /// What this process is, as labels every session it opens is recorded
1064 /// with ([`BlockConfig::session_labels`]).
1065 ///
1066 /// The host's half of `knl.open{ meta = … }`: a caller that runs the same
1067 /// block many times over — a job manager, most of all — says which run
1068 /// this process is, and every session opened here carries it. A script's
1069 /// own labels win on a key they both name, because the script is the one
1070 /// naming what it is recording.
1071 ///
1072 /// Empty is the ordinary case: a script run by hand is not a run of
1073 /// anything, and its sessions carry no label.
1074 pub session_labels: Map<String, Value>,
1075}
1076
1077impl HostContext {
1078 /// Agent id of the connected mesh agent, if any.
1079 ///
1080 /// Returns `Some(agent_id)` when the `mesh` feature is enabled and a mesh
1081 /// agent is connected. Keeps the `#[cfg(feature = "mesh")]` gating out of
1082 /// bridge call sites that only need a fallback agent-id string.
1083 #[cfg(feature = "mesh")]
1084 pub fn mesh_agent_id(&self) -> Option<String> {
1085 self.mesh_agent.as_ref().map(|a| a.agent_id().to_string())
1086 }
1087
1088 /// See the `mesh`-enabled variant. Without the `mesh` feature there is no
1089 /// mesh agent, so this is always `None`.
1090 #[cfg(not(feature = "mesh"))]
1091 pub fn mesh_agent_id(&self) -> Option<String> {
1092 None
1093 }
1094}
1095
1096/// Create the parent directory of a database file, unless the path names an
1097/// in-memory database (which has no parent to create).
1098///
1099/// `label` names the database in the error (`sql` / `kv` / `ts`).
1100#[cfg(feature = "sqlite")]
1101fn prepare_sqlite_dir(path: &Path, label: &'static str) -> BlockResult<bool> {
1102 let is_memory = crate::bridge::config::is_memory_sql(path);
1103 if !is_memory {
1104 if let Some(parent) = path.parent() {
1105 std::fs::create_dir_all(parent)
1106 .map_err(|e| BlockError::Runtime(format!("{label} dir create: {e}")))?;
1107 }
1108 }
1109 Ok(is_memory)
1110}
1111
1112/// Create the directory the kernel's database goes in.
1113///
1114/// The same step [`prepare_sqlite_dir`] does for `sql` / `kv` / `ts`, and a
1115/// function of its own for two reasons: it is not gated behind the `sqlite`
1116/// feature (the kernel is in every build), and the kernel's default path has a
1117/// directory *per project* under the base dir, so there is a level to create
1118/// that the other three never needed.
1119///
1120/// A path with no parent — `AGENT_BLOCK_KNL_PATH=":memory:"`, or a bare file
1121/// name — has no directory to make, and asking for one would be a create of
1122/// the empty path.
1123fn prepare_knl_dir(path: &Path) -> BlockResult<()> {
1124 let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
1125 return Ok(());
1126 };
1127 std::fs::create_dir_all(parent)
1128 .map_err(|e| BlockError::Runtime(format!("knl dir create {}: {e}", parent.display())))
1129}
1130
1131/// Open the SQLite database at `path` (or `:memory:`) on a connection thread
1132/// of its own, and return the handle plus the driver that shuts it down.
1133///
1134/// The ENV-driven pragmas are applied where they cost the caller nothing — the
1135/// busy timeout through the builder (which sets it before anything else runs)
1136/// and `journal_mode` in the init closure, which runs on the connection thread
1137/// before any job does. `init` runs there too, immediately after, which is
1138/// where a bridge's own schema DDL belongs: it waits on SQLite, and waiting is
1139/// the connection thread's business rather than the Lua VM's. The isle owns
1140/// the connection, so `std.ts` — its only caller now that `std.sql` /
1141/// `std.kv` are on [`open_sqlite_conn`] — never takes a lock and never blocks
1142/// the Lua runtime waiting for SQLite.
1143///
1144/// The caller must keep the returned [`rusqlite_isle::AsyncIsleDriver`] and
1145/// shut it down; dropping it alone does not stop the thread.
1146#[cfg(feature = "sqlite")]
1147async fn open_sqlite_isle<F>(
1148 path: &Path,
1149 label: &'static str,
1150 init: F,
1151) -> BlockResult<(rusqlite_isle::AsyncIsle, rusqlite_isle::AsyncIsleDriver)>
1152where
1153 F: FnOnce(&mut rusqlite::Connection) -> Result<(), rusqlite::Error> + Send + 'static,
1154{
1155 let is_memory = prepare_sqlite_dir(path, label)?;
1156 let busy = crate::bridge::config::sql_busy_timeout();
1157 let journal = crate::bridge::config::sql_journal_mode();
1158 let (isle, driver) = rusqlite_isle::AsyncIsle::builder()
1159 .thread_name(label)
1160 .busy_timeout(busy)
1161 .spawn(path, move |conn| {
1162 if !is_memory {
1163 conn.pragma_update(None, "journal_mode", &journal)?;
1164 }
1165 init(conn)
1166 })
1167 .await
1168 .map_err(|e| BlockError::Runtime(format!("sqlite open {}: {e}", path.display())))?;
1169 info!(label, path = %path.display(), busy_ms = busy.as_millis() as i64, "sqlite initialized");
1170 Ok((isle, driver))
1171}
1172
1173/// Open the SQLite database at `path` (or `:memory:`) as a connection the host
1174/// owns and shares, for the bridges that take one that way.
1175///
1176/// This is the other half of the same rule the isle keeps: `std.sql` /
1177/// `std.kv` run their statements inside `tokio::task::spawn_blocking` and lock
1178/// the mutex there, so the VM thread hands the work off and yields instead of
1179/// waiting on SQLite. What it does *not* have is a thread of its own, which is
1180/// why there is no driver to shut down — the connection closes when the last
1181/// clone of the [`SqliteConn`] goes.
1182///
1183/// The setup mirrors [`open_sqlite_isle`] step for step, because these two
1184/// databases used to be opened by it: the parent directory is created,
1185/// `busy_timeout` is applied first, `synchronous` is set to the isle's `NORMAL`
1186/// preset, and the configured `journal_mode` (`WAL` unless overridden) is
1187/// applied to file-backed databases — an in-memory one has no journal to set.
1188/// `init` runs last, on this thread, before the VM exists.
1189#[cfg(feature = "sqlite")]
1190fn open_sqlite_conn<F>(path: &Path, label: &'static str, init: F) -> BlockResult<SqliteConn>
1191where
1192 F: FnOnce(&rusqlite::Connection) -> Result<(), rusqlite::Error>,
1193{
1194 let is_memory = prepare_sqlite_dir(path, label)?;
1195 let busy = crate::bridge::config::sql_busy_timeout();
1196 let journal = crate::bridge::config::sql_journal_mode();
1197
1198 let open = || -> Result<rusqlite::Connection, rusqlite::Error> {
1199 let conn = rusqlite::Connection::open(path)?;
1200 conn.busy_timeout(busy)?;
1201 conn.pragma_update(None, "synchronous", "NORMAL")?;
1202 if !is_memory {
1203 conn.pragma_update(None, "journal_mode", &journal)?;
1204 }
1205 init(&conn)?;
1206 Ok(conn)
1207 };
1208 let conn =
1209 open().map_err(|e| BlockError::Runtime(format!("sqlite open {}: {e}", path.display())))?;
1210
1211 let interrupt = Arc::new(conn.get_interrupt_handle());
1212 info!(label, path = %path.display(), busy_ms = busy.as_millis() as i64, "sqlite initialized");
1213 Ok(SqliteConn {
1214 conn: Arc::new(Mutex::new(conn)),
1215 interrupt,
1216 })
1217}
1218
1219/// Build the init closure shared between the main Isle and the handler
1220/// Isle. Sets `_SCRIPT_NAME`, registers `mlua-batteries` `std.*`, and
1221/// configures `package.path` / `package.searchers` so `require "agent"`
1222/// (and any `blocks/` module) works inside the Lua VM.
1223///
1224/// Returns an `FnOnce` so each call produces a fresh closure; this lets
1225/// both Isles be spawned from the same config without `Clone` bounds on
1226/// the captured `HashMap`.
1227fn build_isle_init(
1228 script_name: String,
1229 script_dir: String,
1230 lib_paths: String,
1231 lib_roots: Vec<PathBuf>,
1232 prompt: Option<String>,
1233 context: Option<String>,
1234 extra_globals: HashMap<String, serde_json::Value>,
1235) -> impl FnOnce(&mlua::Lua) -> mlua::Result<()> + Send + 'static {
1236 move |lua| {
1237 // Set script name before registering bridges (used by log.* for attribution)
1238 lua.globals().set("_SCRIPT_NAME", script_name.as_str())?;
1239 if let Some(ref p) = prompt {
1240 lua.globals().set("_PROMPT", p.as_str())?;
1241 }
1242 if let Some(ref c) = context {
1243 lua.globals().set("_CONTEXT", c.as_str())?;
1244 }
1245
1246 mlua_batteries::register_all(lua, "std")?;
1247
1248 // ── async overrides ───────────────────────────────────────────
1249 // `register_all` gives a `std` that needs no runtime, which means
1250 // its `time.sleep`, `proc.pipeline`, `http.*` and `fs.*` entries
1251 // park the VM thread — and with it every sibling coroutine — for as
1252 // long as the OS takes. This replaces them in place with async ones:
1253 // `tokio::time::sleep` for the sleep, the blocking pool for the
1254 // rest. Lua-side names, arguments, returns and error messages are
1255 // unchanged; what changes is that the VM goes on running.
1256 //
1257 // Both Isles are built from this closure, so one call covers the
1258 // main VM and the handler VM. It has to come after `register_all`
1259 // (there is nothing to override before it); `std.task`, whose
1260 // cancel token `time.sleep` now races, is registered later with the
1261 // other bridges — the overrides read the token from a thread-local
1262 // at call time, not at registration, so the order between them does
1263 // not matter.
1264 //
1265 // The one thing a script must not do is pass a function that calls
1266 // an overridden entry to `std.time.measure`, which calls its
1267 // argument synchronously: the yield would cross a Rust call
1268 // boundary. No block or fixture in this repo does.
1269 mlua_batteries::async_overrides::register_by_name(lua, "std")?;
1270
1271 // ── extra_globals from BlockConfig ──────────────────────────
1272 // Inject SDK-supplied parameterisation values into the Lua
1273 // global namespace. Registered after mlua_batteries so that
1274 // any value that *intentionally* shadows a `std.*` symbol
1275 // wins — callers are responsible for not stomping on bridges
1276 // they need.
1277 for (name, value) in &extra_globals {
1278 let lua_value = crate::bridge::json_to_lua(lua, value.clone())
1279 .map_err(|e| mlua::Error::external(format!("extra_globals[{name}]: {e}")))?;
1280 lua.globals().set(name.as_str(), lua_value)?;
1281 }
1282
1283 // ── package.path ──────────────────────────────────────────────
1284 // Priority: script_dir > project_root/.agent-block/lib/ >
1285 // project_root/lib/ > $AGENT_BLOCK_HOME/lib/ > default — the roots
1286 // `lib_roots` returned, in the order it returned them.
1287 let package: mlua::Table = lua.globals().get("package")?;
1288 let current_path: String = package.get("path")?;
1289 let new_path =
1290 format!("{script_dir}/?.lua;{script_dir}/?/init.lua;{lib_paths}{current_path}");
1291 package.set("path", new_path)?;
1292
1293 // ── require resolution — mlua-pkg Registry ────────────────────
1294 // One priority chain instead of two parallel mechanisms:
1295 //
1296 // script_dir/ > project_root/.agent-block/lib/ > project_root/lib/
1297 // > $AGENT_BLOCK_HOME/lib/ > embedded
1298 //
1299 // with one name space held out of it: `embedded.<name>` resolves from
1300 // memory and only from memory (see below). `blocks/` directories are
1301 // deliberately absent: they hold entry points, not modules (see
1302 // `lib_roots`).
1303 //
1304 // which is exactly the order `package.path` + the old trailing
1305 // searcher produced. The Registry hook installs at the FRONT of
1306 // `package.searchers`, so the filesystem resolvers must be listed
1307 // ahead of the embedded sources here for overrides to keep winning.
1308 //
1309 // `package.path` above is left in place: it still serves plain Lua
1310 // files that predate the Registry and anything a script requires
1311 // relative to itself.
1312 let mut registry = mlua_pkg::Registry::new();
1313
1314 // `embedded.<name>` — the escape hatch out of the priority chain, and
1315 // the reason it is registered FIRST. Registration order is priority
1316 // order (`mlua_pkg::Registry::add`), so a resolver ahead of the
1317 // filesystem ones is the only way to say "this name comes from memory,
1318 // whatever is on disk": a project that happens to have an
1319 // `lib/embedded/agent.lua` cannot make `require("embedded.agent")`
1320 // mean something else, which would turn the delegation idiom into a
1321 // second override. Every key here carries the prefix, so no
1322 // unprefixed name is affected by the position.
1323 let mut aliases = mlua_pkg::resolvers::MemoryResolver::new();
1324 for (name, source) in EMBEDDED_BLOCKS.iter().chain(EMBEDDED_LIBS.iter()) {
1325 aliases = aliases.add(format!("{EMBEDDED_ALIAS_PREFIX}{name}"), *source);
1326 }
1327 aliases = aliases.add(
1328 format!("{EMBEDDED_ALIAS_PREFIX}knl_types"),
1329 crate::bridge::knl::lshape_module_source(),
1330 );
1331 registry.add(aliases);
1332
1333 let mut fs_roots: Vec<PathBuf> = vec![PathBuf::from(&script_dir)];
1334 fs_roots.extend(lib_roots.iter().cloned());
1335 for root in fs_roots {
1336 // Symlink-aware, not the plain constructor: this repo's own
1337 // `blocks/agent` is a symlink into `crates/agent-block-core/blocks/`,
1338 // and the default sandbox rejects anything whose canonical path
1339 // leaves the root. That rejection is `Some(Err)`, which does not
1340 // fall through to the next resolver, so one symlinked block
1341 // directory would break `require` for every module.
1342 match mlua_pkg::resolvers::FsResolver::new_symlink_aware(root.clone()) {
1343 Ok(resolver) => {
1344 registry.add(resolver);
1345 }
1346 Err(e) => {
1347 // A missing directory is expected (script_dir always
1348 // exists, blocks/ roots are optional); anything else is
1349 // worth surfacing without failing the whole run.
1350 warn!(root = %root.display(), error = %e, "FsResolver init skipped");
1351 }
1352 }
1353 }
1354
1355 // Embedded sources baked in at compile time — lowest priority, so a
1356 // filesystem copy of `lib/agent/init.lua` still overrides it.
1357 let mut memory = mlua_pkg::resolvers::MemoryResolver::new();
1358 for (name, source) in EMBEDDED_BLOCKS.iter().chain(EMBEDDED_LIBS.iter()) {
1359 memory = memory.add(*name, *source);
1360 }
1361 // `knl_types` is the one embedded module with no file behind it: the
1362 // lshape declaration of the kernel's syscall surface, generated here
1363 // from the Rust argument and return types in `bridge/knl.rs`. It is
1364 // built at start rather than checked in because a generated file in
1365 // the tree is a file that can be edited, and one that has been edited
1366 // is a second declaration wearing the first one's name — which is
1367 // exactly the drift the Lua kernel's registry stopped having when it
1368 // started pointing at this. Same lowest priority as the rest: a
1369 // filesystem `knl_types` would win, and would be the caller's own.
1370 memory = memory.add("knl_types", crate::bridge::knl::lshape_module_source());
1371 registry.add(memory);
1372
1373 registry
1374 .install(lua)
1375 .map_err(|e| mlua::Error::external(format!("require registry install failed: {e}")))?;
1376
1377 Ok(())
1378 }
1379}
1380
1381/// Spawn the dedicated handler Isle.
1382///
1383/// The handler Isle runs Lua bus handlers (`bus.on` / `bus.on_any`) on a
1384/// separate OS thread with its own `tokio` current-thread runtime, keeping
1385/// CPU-bound handlers from starving the main Isle's grace timers.
1386///
1387/// Bridge registration is deferred to a follow-up `exec` in `run()` because
1388/// `HostContext` is not constructible until both Isles exist (the struct
1389/// itself holds `Arc<AsyncIsle>` for both).
1390async fn spawn_handler_isle(
1391 script_name: String,
1392 script_dir: String,
1393 lib_paths: String,
1394 lib_roots: Vec<PathBuf>,
1395 prompt: Option<String>,
1396 context: Option<String>,
1397 extra_globals: HashMap<String, serde_json::Value>,
1398) -> BlockResult<(Arc<AsyncIsle>, AsyncIsleDriver)> {
1399 let init = build_isle_init(
1400 script_name,
1401 script_dir,
1402 lib_paths,
1403 lib_roots,
1404 prompt,
1405 context,
1406 extra_globals,
1407 );
1408 let (isle, driver) = AsyncIsle::builder()
1409 .thread_name("agent-block-handler-isle")
1410 .spawn(init)
1411 .await
1412 .map_err(|e| BlockError::Runtime(format!("handler isle spawn failed: {e}")))?;
1413 info!(
1414 thread_name = "agent-block-handler-isle",
1415 "handler Isle spawned"
1416 );
1417 Ok((Arc::new(isle), driver))
1418}
1419
1420#[cfg(feature = "mesh")]
1421fn hex_decode_32(s: &str) -> Result<[u8; 32], String> {
1422 let s = s.trim();
1423 if s.len() != 64 {
1424 return Err(format!("expected 64 hex chars, got {}", s.len()));
1425 }
1426 let mut out = [0u8; 32];
1427 for (i, byte) in out.iter_mut().enumerate() {
1428 let hi = u8::from_str_radix(&s[2 * i..2 * i + 1], 16)
1429 .map_err(|e| format!("invalid hex at position {}: {e}", 2 * i))?;
1430 let lo = u8::from_str_radix(&s[2 * i + 1..2 * i + 2], 16)
1431 .map_err(|e| format!("invalid hex at position {}: {e}", 2 * i + 1))?;
1432 *byte = (hi << 4) | lo;
1433 }
1434 Ok(out)
1435}
1436
1437/// Concrete payloads resolved from the `*Source` enums on [`BlockConfig`]
1438/// before any Isle setup begins.
1439struct ResolvedSources {
1440 script_source: String,
1441 script_name: String,
1442 script_dir: PathBuf,
1443 prompt: Option<String>,
1444 context: Option<String>,
1445 secret_key: Option<String>,
1446}
1447
1448/// Resolve the script / prompt / context / secret-key sources to their
1449/// concrete values, reading from disk or environment exactly once.
1450fn resolve_sources(config: &BlockConfig) -> BlockResult<ResolvedSources> {
1451 let (script_source, script_name, script_dir) = match &config.script {
1452 ScriptSource::Path(p) => {
1453 let source = std::fs::read_to_string(p)
1454 .map_err(|e| BlockError::Script(format!("{}: {e}", p.display())))?;
1455 let name = p
1456 .file_name()
1457 .map(|n| n.to_string_lossy().to_string())
1458 .unwrap_or_else(|| "unknown".to_string());
1459 let dir = p
1460 .parent()
1461 .map(|d| d.to_path_buf())
1462 .unwrap_or_else(|| PathBuf::from("."));
1463 (source, name, dir)
1464 }
1465 ScriptSource::Inline { source, name } => {
1466 (source.clone(), name.clone(), config.project_root.clone())
1467 }
1468 ScriptSource::DefaultAgent => (
1469 DEFAULT_AGENT_INVOKER.to_string(),
1470 "default_agent_invoker.lua".to_string(),
1471 config.project_root.clone(),
1472 ),
1473 };
1474
1475 let prompt: Option<String> = match &config.prompt {
1476 Some(PromptSource::Inline(s)) => Some(s.clone()),
1477 Some(PromptSource::File(p)) => Some(
1478 std::fs::read_to_string(p)
1479 .map_err(|e| BlockError::Script(format!("prompt file {}: {e}", p.display())))?,
1480 ),
1481 None => None,
1482 };
1483 let context: Option<String> = match &config.context {
1484 Some(PromptSource::Inline(s)) => Some(s.clone()),
1485 Some(PromptSource::File(p)) => Some(
1486 std::fs::read_to_string(p)
1487 .map_err(|e| BlockError::Script(format!("context file {}: {e}", p.display())))?,
1488 ),
1489 None => None,
1490 };
1491 let secret_key: Option<String> = match &config.secret_key {
1492 Some(SecretKeySource::Inline(s)) => Some(s.clone()),
1493 Some(SecretKeySource::Env(var)) => std::env::var(var).ok(),
1494 None => None,
1495 };
1496
1497 Ok(ResolvedSources {
1498 script_source,
1499 script_name,
1500 script_dir,
1501 prompt,
1502 context,
1503 secret_key,
1504 })
1505}
1506
1507/// Load `.env` from the project root into the process environment so Lua's
1508/// `std.env.get()` observes it. A missing file is intentionally ignored.
1509fn load_dotenv(project_root: &Path) {
1510 let env_path = project_root.join(".env");
1511 match dotenvy::from_path(&env_path) {
1512 Ok(()) => info!(path = %env_path.display(), ".env loaded"),
1513 Err(dotenvy::Error::Io(_)) => {} // file not found — fine
1514 Err(e) => tracing::warn!(path = %env_path.display(), error = %e, ".env parse error"),
1515 }
1516}
1517
1518/// Background auto-serve dispatcher task handle plus its cancellation token,
1519/// or `None` when auto-serve is disabled.
1520type AutoServeState = Option<(tokio::task::JoinHandle<()>, CancellationToken)>;
1521
1522/// EventBus wiring produced by [`setup_event_bus`].
1523struct BusSetup {
1524 event_bus: Arc<Mutex<Option<EventBus>>>,
1525 bus_tx: mpsc::Sender<Event>,
1526 auto_serve_state: AutoServeState,
1527}
1528
1529/// Construct the bounded EventBus channel, pre-install host-side Rust
1530/// handlers, and (when `auto_serve_bus` is set with at least one handler)
1531/// spawn the background dispatcher loop before the script runs.
1532fn setup_event_bus(config: &BlockConfig) -> BlockResult<BusSetup> {
1533 // Construct the bounded mpsc BEFORE MeshAgent::connect so the relay
1534 // handler can hold a `bus_tx` clone and forward incoming requests
1535 // into the dispatcher. Capacity is ENV-driven (see bridge::config).
1536 let bus_capacity = crate::bridge::config::bus_capacity();
1537 let (bus_tx, bus_rx) = mpsc::channel::<Event>(bus_capacity);
1538 let event_bus = Arc::new(Mutex::new(Some(EventBus::new(bus_rx))));
1539
1540 // Install host-side Rust handlers: kind-specific entries from
1541 // `host_handlers` and, when set, the kind-agnostic `host_handler`
1542 // (registered via `on_any` as the fallback for unmatched kinds).
1543 // Registered before any Lua bridge registers handlers and before
1544 // `bus.serve` takes ownership, so the EventBus already carries the
1545 // host handlers when the script starts.
1546 let has_kind_handlers = !config.host_handlers.is_empty();
1547 let has_any_handler = config.host_handler.is_some();
1548 if has_kind_handlers || has_any_handler {
1549 let mut guard = event_bus
1550 .lock()
1551 .map_err(|_| BlockError::Bus("event_bus mutex poisoned".into()))?;
1552 let bus = guard
1553 .as_mut()
1554 .ok_or_else(|| BlockError::Bus("event_bus already taken".into()))?;
1555 for (kind, handler) in &config.host_handlers {
1556 bus.on(kind.clone(), Arc::clone(handler))
1557 .map_err(|e| BlockError::Bus(format!("host_handlers on({kind}): {e}")))?;
1558 }
1559 if let Some(any_handler) = &config.host_handler {
1560 bus.on_any(Arc::clone(any_handler))
1561 .map_err(|e| BlockError::Bus(format!("host_handler on_any: {e}")))?;
1562 }
1563 info!(
1564 kind_handlers = config.host_handlers.len(),
1565 any_handler = has_any_handler,
1566 "host handlers pre-installed"
1567 );
1568 }
1569
1570 // auto-serve: when enabled with at least one host-side handler, take the
1571 // EventBus out of the Mutex *before* the script runs and spawn the
1572 // dispatcher loop on the runtime. This lets `bus.emit(kind, payload)`
1573 // from the script reach the host handler without requiring the script to
1574 // call `bus.serve()` (which blocks on signals and never returns under
1575 // programmatic embedding).
1576 let auto_serve = config.auto_serve_bus && (has_kind_handlers || has_any_handler);
1577 let auto_serve_state: AutoServeState = if auto_serve {
1578 let bus = {
1579 let mut guard = event_bus
1580 .lock()
1581 .map_err(|_| BlockError::Bus("event_bus mutex poisoned".into()))?;
1582 guard
1583 .take()
1584 .ok_or_else(|| BlockError::Bus("event_bus already taken".into()))?
1585 };
1586 let token = CancellationToken::new();
1587 let token_for_task = token.clone();
1588 let handle = tokio::spawn(async move {
1589 let mut bus = bus;
1590 if let Err(e) = bus.run(token_for_task).await {
1591 tracing::error!(error = %e, "auto-serve: dispatcher loop returned error");
1592 }
1593 });
1594 info!("auto-serve: dispatcher spawned");
1595 Some((handle, token))
1596 } else {
1597 None
1598 };
1599
1600 Ok(BusSetup {
1601 event_bus,
1602 bus_tx,
1603 auto_serve_state,
1604 })
1605}
1606
1607/// Connect to the mesh relay when `relay_url` is set, deriving the Ed25519
1608/// identity from `secret_key` (or a fresh random keypair) and wiring the
1609/// EventBus relay handler. Returns `None` when mesh is disabled.
1610#[cfg(feature = "mesh")]
1611async fn connect_mesh(
1612 relay_url: Option<&String>,
1613 secret_key: Option<&String>,
1614 bus_tx: &mpsc::Sender<Event>,
1615) -> BlockResult<Option<Arc<agent_mesh_sdk::MeshAgent>>> {
1616 let Some(relay_url) = relay_url else {
1617 return Ok(None);
1618 };
1619 let keypair = match secret_key {
1620 Some(hex_str) => {
1621 let bytes = hex_decode_32(hex_str)
1622 .map_err(|e| BlockError::Runtime(format!("secret-key: {e}")))?;
1623 agent_mesh_core::identity::AgentKeypair::from_bytes(&bytes)
1624 }
1625 None => agent_mesh_core::identity::AgentKeypair::generate(),
1626 };
1627 info!(agent_id = %keypair.agent_id(), "mesh identity");
1628 let acl = agent_mesh_core::acl::AclPolicy {
1629 default_deny: false,
1630 rules: vec![],
1631 };
1632 let handler: Arc<dyn agent_mesh_sdk::RequestHandler> =
1633 Arc::new(BusRelayHandler::new(bus_tx.clone()));
1634 let url = relay_url.clone();
1635 let agent = agent_mesh_sdk::MeshAgent::connect(keypair, &url, acl, handler)
1636 .await
1637 .map_err(|e| BlockError::Mesh(format!("connect to {relay_url} failed: {e}")))?;
1638 info!(relay_url = %relay_url, "mesh connected");
1639 Ok(Some(Arc::new(agent)))
1640}
1641
1642/// The three SQLite databases backing the `sql.*`, `kv.*`, and `ts.*` Lua
1643/// bridges.
1644///
1645/// Two shapes, one rule. `sql` and `kv` are connections the host owns and
1646/// shares, whose statements go to the blocking pool; `ts` is a connection
1647/// thread the statements are sent to. Either way the VM thread hands the work
1648/// off and yields — it never waits on SQLite itself.
1649#[cfg(feature = "sqlite")]
1650struct SqliteConns {
1651 sql: SqliteConn,
1652 kv: SqliteConn,
1653 ts_isle: rusqlite_isle::AsyncIsle,
1654 drivers: SqliteDrivers,
1655}
1656
1657/// The lifecycle owner of the `ts` connection thread.
1658///
1659/// Kept out of [`HostContext`] (which is cloned into every bridge) because a
1660/// driver is not clonable by design: there is exactly one, held by the run
1661/// loop until [`shutdown`] joins the thread. `sql` and `kv` have no entry
1662/// here — a shared connection closes with its last [`SqliteConn`] clone,
1663/// which is when the Lua VMs holding them are gone.
1664#[cfg(feature = "sqlite")]
1665struct SqliteDrivers {
1666 ts: rusqlite_isle::AsyncIsleDriver,
1667}
1668
1669/// Open the sql / kv / ts SQLite databases, honoring the [`BlockConfig`]
1670/// path overrides and otherwise falling back to the env-driven resolution.
1671#[cfg(feature = "sqlite")]
1672async fn init_sqlite(config: &BlockConfig) -> BlockResult<SqliteConns> {
1673 let sql_path = match &config.sql_path {
1674 Some(p) => p.clone(),
1675 None => crate::bridge::config::sql_path().map_err(BlockError::Runtime)?,
1676 };
1677 let sql = open_sqlite_conn(&sql_path, "sql", |_| Ok(()))?;
1678
1679 let kv_path = match &config.kv_path {
1680 Some(p) => p.clone(),
1681 None => crate::bridge::config::kv_path().map_err(BlockError::Runtime)?,
1682 };
1683 // The `__kv` table is ensured here, while the connection is still the
1684 // host's alone — so `bridge::kv::register` has nothing left to do but
1685 // hand the connection over, and the Lua VM never runs DDL.
1686 let kv = open_sqlite_conn(&kv_path, "kv", mlua_batteries_sqlite::kv::init_schema)?;
1687
1688 let ts_path = match &config.ts_path {
1689 Some(p) => p.clone(),
1690 None => crate::bridge::config::ts_path().map_err(BlockError::Runtime)?,
1691 };
1692 // Same for `ts`, on the connection thread, before the isle takes its
1693 // first job.
1694 let (ts_isle, ts_driver) = open_sqlite_isle(&ts_path, "ts", |conn| {
1695 conn.execute_batch(crate::bridge::ts::SCHEMA_DDL)
1696 })
1697 .await?;
1698
1699 Ok(SqliteConns {
1700 sql,
1701 kv,
1702 ts_isle,
1703 drivers: SqliteDrivers { ts: ts_driver },
1704 })
1705}
1706
1707/// The main and handler Isles plus their drivers, produced by [`spawn_isles`].
1708struct SpawnedIsles {
1709 isle: Arc<AsyncIsle>,
1710 driver: AsyncIsleDriver,
1711 handler_isle: Arc<AsyncIsle>,
1712 handler_driver: AsyncIsleDriver,
1713}
1714
1715/// Spawn the main Lua Isle and the dedicated handler Isle from the same
1716/// resolved script parameters. Their bridges are registered in a later pass
1717/// (via [`register_bridges`]) once the `HostContext` exists.
1718async fn spawn_isles(
1719 script_name: &str,
1720 script_dir: &str,
1721 lib_paths: &str,
1722 lib_roots: &[PathBuf],
1723 prompt: Option<String>,
1724 context: Option<String>,
1725 extra_globals: &HashMap<String, serde_json::Value>,
1726) -> BlockResult<SpawnedIsles> {
1727 let (isle, driver) = AsyncIsle::spawn(build_isle_init(
1728 script_name.to_string(),
1729 script_dir.to_string(),
1730 lib_paths.to_string(),
1731 lib_roots.to_vec(),
1732 prompt.clone(),
1733 context.clone(),
1734 extra_globals.clone(),
1735 ))
1736 .await
1737 .map_err(|e| BlockError::Runtime(format!("AsyncIsle spawn failed: {e}")))?;
1738 let isle = Arc::new(isle);
1739
1740 // handler Isle (sequential, dependencies are trivial)
1741 let (handler_isle, handler_driver) = spawn_handler_isle(
1742 script_name.to_string(),
1743 script_dir.to_string(),
1744 lib_paths.to_string(),
1745 lib_roots.to_vec(),
1746 prompt,
1747 context,
1748 extra_globals.clone(),
1749 )
1750 .await?;
1751
1752 Ok(SpawnedIsles {
1753 isle,
1754 driver,
1755 handler_isle,
1756 handler_driver,
1757 })
1758}
1759
1760/// Register the Lua stdlib bridges on both the main Isle
1761/// (`bridge::register_all`) and the handler Isle
1762/// (`bridge::register_all_handler_side`).
1763async fn register_bridges(
1764 ctx: &HostContext,
1765 isle: &Arc<AsyncIsle>,
1766 handler_isle: &Arc<AsyncIsle>,
1767) -> BlockResult<()> {
1768 {
1769 let ctx = ctx.clone();
1770 isle.exec(move |lua| {
1771 bridge::register_all(lua, &ctx)
1772 .map_err(|e| mlua_isle::IsleError::Lua(format!("bridge register failed: {e}")))?;
1773 Ok(String::new())
1774 })
1775 .await
1776 .map_err(|e| BlockError::Runtime(format!("bridge register: {e}")))?;
1777 }
1778
1779 {
1780 let ctx = ctx.clone();
1781 handler_isle
1782 .exec(move |lua| {
1783 bridge::register_all_handler_side(lua, &ctx).map_err(|e| {
1784 mlua_isle::IsleError::Lua(format!("handler bridge register failed: {e}"))
1785 })?;
1786 Ok(String::new())
1787 })
1788 .await
1789 .map_err(|e| BlockError::Runtime(format!("handler bridge register: {e}")))?;
1790 }
1791
1792 Ok(())
1793}
1794
1795/// Inject the [`BlockConfig::host_tools`] Rust tools into the Lua
1796/// `_TOOL_REGISTRY` so they are indistinguishable from Lua-defined tools.
1797/// Each entry becomes an Anthropic-shaped tool spec table
1798/// (`{ name, schema = { description, input_schema }, handler, group? }`)
1799/// whose `handler` bridges back into the supplied `ToolHandler::call`.
1800/// No-op when no host tools are supplied.
1801async fn inject_host_tools(isle: &Arc<AsyncIsle>, host_tools: &[HostToolSpec]) -> BlockResult<()> {
1802 if host_tools.is_empty() {
1803 return Ok(());
1804 }
1805 let host_tools = host_tools.to_vec();
1806 let tool_count = host_tools.len();
1807 isle.exec(move |lua| {
1808 let registry: mlua::Table = lua
1809 .globals()
1810 .get("_TOOL_REGISTRY")
1811 .map_err(|e| mlua_isle::IsleError::Lua(format!("get _TOOL_REGISTRY: {e}")))?;
1812 for tool in host_tools {
1813 let entry = lua
1814 .create_table()
1815 .map_err(|e| mlua_isle::IsleError::Lua(format!("create entry: {e}")))?;
1816 entry
1817 .set("name", tool.name.as_str())
1818 .map_err(|e| mlua_isle::IsleError::Lua(format!("set name: {e}")))?;
1819 // schema = { description, input_schema } — Anthropic shape
1820 let schema = lua
1821 .create_table()
1822 .map_err(|e| mlua_isle::IsleError::Lua(format!("create schema: {e}")))?;
1823 schema
1824 .set("description", tool.description.as_str())
1825 .map_err(|e| mlua_isle::IsleError::Lua(format!("set description: {e}")))?;
1826 let input_schema_lua = crate::bridge::json_to_lua(lua, tool.input_schema.clone())
1827 .map_err(|e| mlua_isle::IsleError::Lua(format!("input_schema: {e}")))?;
1828 schema
1829 .set("input_schema", input_schema_lua)
1830 .map_err(|e| mlua_isle::IsleError::Lua(format!("set input_schema: {e}")))?;
1831 entry
1832 .set("schema", schema)
1833 .map_err(|e| mlua_isle::IsleError::Lua(format!("set schema: {e}")))?;
1834 if let Some(group) = &tool.group {
1835 entry
1836 .set("group", group.as_str())
1837 .map_err(|e| mlua_isle::IsleError::Lua(format!("set group: {e}")))?;
1838 }
1839 let handler_arc = Arc::clone(&tool.handler);
1840 let handler_fn = lua
1841 .create_async_function(move |lua, input: mlua::Value| {
1842 let handler = Arc::clone(&handler_arc);
1843 async move {
1844 let input_json = crate::bridge::lua_to_json(&lua, input)?;
1845 let result = handler
1846 .call(input_json)
1847 .await
1848 .map_err(mlua::Error::external)?;
1849 crate::bridge::json_to_lua(&lua, result)
1850 }
1851 })
1852 .map_err(|e| mlua_isle::IsleError::Lua(format!("create handler: {e}")))?;
1853 entry
1854 .set("handler", handler_fn)
1855 .map_err(|e| mlua_isle::IsleError::Lua(format!("set handler: {e}")))?;
1856 registry
1857 .set(tool.name.as_str(), entry)
1858 .map_err(|e| mlua_isle::IsleError::Lua(format!("registry set: {e}")))?;
1859 }
1860 Ok(String::new())
1861 })
1862 .await
1863 .map_err(|e| BlockError::Runtime(format!("host_tools inject: {e}")))?;
1864 info!(count = tool_count, "host tools injected into Lua registry");
1865 Ok(())
1866}
1867
1868/// Execute the resolved Lua script on the main Isle, racing it against the
1869/// optional caller `shutdown_token`. On cancellation the Isle is unwound via
1870/// its own cancel token before returning [`BlockError::Cancelled`].
1871///
1872/// The returned string is the chunk's value, stringified by the Isle: a Lua
1873/// string passes through unchanged, `nil` becomes empty, and a table becomes
1874/// `table: 0x…`. A caller that wants structured data back therefore has the
1875/// script `return std.json.encode(t)` — see [`run_capture`].
1876async fn execute_script(
1877 isle: &Arc<AsyncIsle>,
1878 script_source: &str,
1879 script_name: &str,
1880 shutdown_token: Option<&CancellationToken>,
1881) -> BlockResult<String> {
1882 let _exec_span = info_span!("execute", script = %script_name);
1883
1884 let mut task = isle.spawn_coroutine_eval(script_source);
1885 let task_cancel = task.cancel_token().clone();
1886 match shutdown_token {
1887 Some(token) => {
1888 tokio::select! {
1889 biased;
1890 _ = token.cancelled() => {
1891 task_cancel.cancel();
1892 // Wait for the Isle to unwind so the VM is in a
1893 // consistent state before driver shutdown. The
1894 // debug hook fires at the next HOOK_INTERVAL.
1895 let _ = (&mut task).await;
1896 info!("shutdown_token: cancelled by caller");
1897 Err(BlockError::Cancelled)
1898 }
1899 res = &mut task => res.map_err(script_failure),
1900 }
1901 }
1902 None => (&mut task).await.map_err(script_failure),
1903 }
1904}
1905
1906/// What a block says when it has looked at what it needs and is not
1907/// starting: `job.defer(reason)` in `blocks/lib/job/init.lua` raises a
1908/// string carrying this prefix, and it is the one contract between that
1909/// module and this file. Kept in step by hand; the spec on the Lua side and
1910/// the test below each pin their half.
1911const DEFER_PREFIX: &str = "job.defer: ";
1912
1913/// The script's failure, as the error the caller is handed.
1914///
1915/// A raise that carries [`DEFER_PREFIX`] is [`BlockError::Deferred`] with
1916/// the reason after it — the block did not start, and the CLI exits
1917/// `EX_TEMPFAIL` on it so a job manager records the run as such. Anything
1918/// else is [`BlockError::Script`] with the text as it came. The prefix is
1919/// searched, not matched at the front, because a Lua raise reaches here
1920/// wrapped in the runtime's own words (the chunk name, the line, a
1921/// traceback in dev mode) ahead of the message.
1922fn script_failure(e: impl std::fmt::Display) -> BlockError {
1923 let text = e.to_string();
1924 match text.find(DEFER_PREFIX) {
1925 Some(at) => {
1926 let reason = text[at + DEFER_PREFIX.len()..]
1927 .lines()
1928 .next()
1929 .unwrap_or("")
1930 .trim()
1931 .to_string();
1932 BlockError::Deferred(reason)
1933 }
1934 None => BlockError::Script(text),
1935 }
1936}
1937
1938/// Drain the auto-serve dispatcher: give it a grace window to flush queued
1939/// events, then cancel and bound-join it. No-op when auto-serve is off.
1940async fn drain_auto_serve(auto_serve_state: AutoServeState) {
1941 if let Some((handle, token)) = auto_serve_state {
1942 let grace_ms = crate::bridge::config::task_grace_ms();
1943 let grace = Duration::from_millis(grace_ms);
1944 tokio::time::sleep(grace).await;
1945 token.cancel();
1946 match tokio::time::timeout(grace, handle).await {
1947 Ok(Ok(())) => info!("auto-serve: dispatcher shut down cleanly"),
1948 Ok(Err(join_err)) => {
1949 tracing::error!(error = %join_err, "auto-serve: dispatcher task join error");
1950 }
1951 Err(_) => {
1952 tracing::warn!(
1953 grace_ms,
1954 "auto-serve: dispatcher join timed out after cancel; forcing exit"
1955 );
1956 }
1957 }
1958 }
1959}
1960
1961/// Tear down host resources in order: disconnect MCP servers, shut down the
1962/// main Isle driver, then the handler Isle driver, the kernel's per-session
1963/// connection threads and the `ts` one.
1964///
1965/// **Every step runs, whatever the step before it did.** A teardown is not a
1966/// pipeline: each of these owns something that has to be let go of, and the
1967/// drains at the end are where queued writes actually land — the kernel's
1968/// connection threads hold the `session_closed` a dropped handle submitted
1969/// without waiting for it ([`crate::knl::Session::close_detached`]), and a
1970/// `?` on an earlier step used to skip them, so a failing MCP disconnect or a
1971/// panicking main Isle silently cost the log its closing boundaries. So the
1972/// failures are collected and the first one is returned once everything has
1973/// been drained; the ones that are logged rather than returned stay logged,
1974/// for the same reason as before — a worker-thread panic must not poison the
1975/// process exit when the script's own result is what the caller asked for.
1976///
1977/// The kernel's logs go *after* the Isles on purpose. Dropping a VM runs its
1978/// collector, which is where a session nobody closed submits its
1979/// `session_closed` without waiting for it — so those logs have to be alive to
1980/// take that write, and drained only once nothing can still be handed to them.
1981async fn shutdown(
1982 mcp_manager: &Arc<RwLock<McpManager>>,
1983 driver: AsyncIsleDriver,
1984 handler_driver: AsyncIsleDriver,
1985 knl_logs: crate::knl::Logs,
1986 #[cfg(feature = "sqlite")] sqlite_drivers: SqliteDrivers,
1987) -> BlockResult<()> {
1988 let _shutdown_span = info_span!("shutdown");
1989
1990 // What the caller is told about, once the rest of the teardown has run.
1991 // The first failure wins: it is the one nearest the cause, and the others
1992 // are in the log with their own context.
1993 let mut failure: Option<BlockError> = None;
1994 let mut record = |e: BlockError| {
1995 tracing::error!(error = %e, "shutdown step failed; the teardown continues");
1996 if failure.is_none() {
1997 failure = Some(e);
1998 }
1999 };
2000
2001 if let Err(e) = mcp_manager.write().await.disconnect_all().await {
2002 record(e);
2003 }
2004
2005 if let Err(e) = driver.shutdown().await {
2006 record(BlockError::Runtime(format!(
2007 "AsyncIsle shutdown failed: {e}"
2008 )));
2009 }
2010
2011 // Handler Isle shutdown is independent of main shutdown: a failure
2012 // here (e.g. ThreadPanic on the handler thread) is logged but does
2013 // not poison the main process exit. The main Isle has already
2014 // been stopped above.
2015 match handler_driver.shutdown().await {
2016 Ok(()) => info!(
2017 thread_name = "agent-block-handler-isle",
2018 "handler Isle shut down"
2019 ),
2020 Err(e) => tracing::error!(
2021 error = %e,
2022 thread_name = "agent-block-handler-isle",
2023 "handler Isle shutdown failed"
2024 ),
2025 }
2026
2027 // The kernel's event logs. Both Isles are gone by now, so every Lua
2028 // session userdata has been collected and every drop backstop has
2029 // submitted its boundary; a graceful shutdown is what runs those queued
2030 // writes before the connection threads exit.
2031 {
2032 let count = knl_logs.len().await;
2033 let failures = knl_logs.shutdown().await;
2034 if failures.is_empty() {
2035 if count > 0 {
2036 info!(count, "knl event logs shut down");
2037 }
2038 } else {
2039 for e in &failures {
2040 tracing::error!(error = %e, "knl event log shutdown failed");
2041 }
2042 }
2043 }
2044
2045 // The `ts` connection thread: a graceful shutdown drains whatever the
2046 // script left queued before the thread exits. Logged rather than fatal,
2047 // for the same reason as the handler Isle above — the script has already
2048 // run, and its result is what the caller asked for. The `sql` / `kv`
2049 // connections need nothing here: they have no thread, and the last
2050 // reference to each went with the VMs shut down above.
2051 #[cfg(feature = "sqlite")]
2052 {
2053 let SqliteDrivers { ts } = sqlite_drivers;
2054 match ts.shutdown().await {
2055 Ok(()) => info!(label = "ts", "sqlite connection thread shut down"),
2056 Err(e) => {
2057 tracing::error!(error = %e, label = "ts", "sqlite shutdown failed")
2058 }
2059 }
2060 }
2061
2062 match failure {
2063 Some(e) => Err(e),
2064 None => Ok(()),
2065 }
2066}
2067
2068/// Primary SDK entry point: run one agent-block execution to completion.
2069///
2070/// Given a fully-populated [`BlockConfig`], this drives the entire host
2071/// orchestration for a single run: it resolves the script / prompt / context /
2072/// secret-key sources, loads `.env` from the project root, spawns the main and
2073/// handler Lua Isles, opens the kv / sql / ts SQLite connections, optionally
2074/// connects to the mesh relay, initialises the MCP manager, injects the Lua
2075/// stdlib bridge plus any host-supplied tools / handlers, executes the script,
2076/// and finally tears everything down (MCP disconnect, Isle shutdown, auto-serve
2077/// dispatcher join).
2078///
2079/// The returned future is `Send`, so SDK consumers may `tokio::spawn` it.
2080///
2081/// # Errors
2082///
2083/// Returns [`BlockError`] when any stage fails: source resolution / file reads
2084/// ([`BlockError::Script`]), mesh connect ([`BlockError::Mesh`]), EventBus or
2085/// Isle setup ([`BlockError::Bus`] / [`BlockError::Runtime`]), or a script
2086/// runtime error ([`BlockError::Script`]) — except the one raise a block
2087/// makes on purpose, `job.defer(reason)`, which is [`BlockError::Deferred`]
2088/// so the CLI can exit `EX_TEMPFAIL` on it. When a `shutdown_token` is supplied
2089/// and fires before the script finishes, returns [`BlockError::Cancelled`]
2090/// after the shutdown sequence completes.
2091///
2092/// Use [`run_capture`] when the caller needs the script's value rather than
2093/// only its success.
2094pub async fn run(config: BlockConfig) -> BlockResult<()> {
2095 run_capture(config).await.map(|_| ())
2096}
2097
2098/// [`run`], returning what the script evaluated to.
2099///
2100/// Same orchestration, one difference: the chunk's value comes back instead of
2101/// being dropped. It arrives stringified — a Lua string unchanged, `nil` as the
2102/// empty string, a table as `table: 0x…`, which is useless to a caller. So the
2103/// contract for a script meant to be consumed this way is to **return a JSON
2104/// string**:
2105///
2106/// ```lua
2107/// return std.json.encode({ ok = true, summary = "…" })
2108/// ```
2109///
2110/// This exists for hosts that invoke a block on someone else's behalf and have
2111/// to hand the outcome back across a boundary — the MCP server mode
2112/// (`agent-block mcp`) is the first such caller. Nothing about the run differs;
2113/// a script that returns nothing simply yields an empty string.
2114pub async fn run_capture(config: BlockConfig) -> BlockResult<String> {
2115 // `sh.exec` puts each command in a process group of its own so a timeout
2116 // reaches what the command started. That also takes the command out of the
2117 // group the terminal signals, so the host forwards the signal on. Install
2118 // once; the call is idempotent and a server host reaches it per request.
2119 bridge::sh::install_signal_cleanup();
2120
2121 // ── Resolve sources ───────────────────────────────────────────
2122 // Convert the `Source` enums on `BlockConfig` to their concrete
2123 // payloads before any Isle setup. `File`/`Path`/`Env` variants
2124 // read from disk / environment exactly once, here at the start.
2125 let ResolvedSources {
2126 script_source,
2127 script_name,
2128 script_dir: script_dir_pathbuf,
2129 prompt: prompt_resolved,
2130 context: context_resolved,
2131 secret_key: secret_key_resolved,
2132 } = resolve_sources(&config)?;
2133
2134 // NOTE: We previously held entered span guards across awaits for nested
2135 // span context. That made the `run()` future `!Send`, which prevents
2136 // SDK consumers from `tokio::spawn(run(config))`. Span context is
2137 // attached to events via fields on the `info_span!` calls below; the
2138 // missing nesting is an acceptable trade-off for `Send` correctness.
2139 let _root_span = info_span!("agent_block", script = %script_name);
2140
2141 // ── .env ──────────────────────────────────────────────────────
2142 // Load .env from project_root if present. Variables are merged into
2143 // the process environment so Lua's `std.env.get()` picks them up.
2144 load_dotenv(&config.project_root);
2145
2146 // ── Init ──────────────────────────────────────────────────────
2147 let _init_span = info_span!("init");
2148
2149 // ── EventBus + host handlers + auto-serve dispatcher ──────────────
2150 // Construct the bus channel, pre-install host-side Rust handlers, and
2151 // (when configured) spawn the background dispatcher before the script.
2152 let BusSetup {
2153 event_bus,
2154 bus_tx,
2155 auto_serve_state,
2156 } = setup_event_bus(&config)?;
2157
2158 #[cfg(feature = "mesh")]
2159 let mesh_agent = connect_mesh(
2160 config.relay_url.as_ref(),
2161 secret_key_resolved.as_ref(),
2162 &bus_tx,
2163 )
2164 .await?;
2165
2166 // The HTTP source, when asked for: bound now so a bind failure is the
2167 // run's error rather than a listener that quietly never came up, and
2168 // stopped after the script returns (below), alongside the auto-serve
2169 // dispatcher.
2170 let http_source = match &config.http_source {
2171 Some(cfg) => Some(
2172 crate::bus::http_source::start(cfg.clone(), bus_tx.clone())
2173 .await
2174 .map_err(BlockError::Bus)?,
2175 ),
2176 None => None,
2177 };
2178 // `secret_key` / `relay_url` are consumed only by the mesh connect path.
2179 #[cfg(not(feature = "mesh"))]
2180 let _ = (&secret_key_resolved, &config.relay_url);
2181
2182 let mcp_manager = Arc::new(RwLock::new(McpManager::with_rpc_timeout(
2183 config.mcp_rpc_timeout,
2184 )?));
2185
2186 // Resolve project_root to absolute path.
2187 // canonicalize() can fail if the path doesn't exist; fall back to
2188 // joining with current_dir to guarantee an absolute path.
2189 let project_root = config
2190 .project_root
2191 .canonicalize()
2192 .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(&config.project_root)))?;
2193
2194 // HTTP client: prefer the SDK-supplied client if any; otherwise
2195 // construct a fresh default reqwest::Client (legacy behavior).
2196 let http_client = config.http_client.clone().unwrap_or_default();
2197
2198 // ── SQLite init (sql + kv + ts get separate DB files) ─────────────
2199 // BlockConfig overrides take precedence; otherwise the env-driven
2200 // resolution in `bridge::config::*` applies (see crate docs). Gated
2201 // behind the `sqlite` feature; when off, the sql/kv/ts bridges are not
2202 // registered and the `sql_path` / `kv_path` / `ts_path` config fields
2203 // (which remain present for API stability) are ignored.
2204 #[cfg(feature = "sqlite")]
2205 let SqliteConns {
2206 sql: sql_conn,
2207 kv: kv_conn,
2208 ts_isle,
2209 drivers: sqlite_drivers,
2210 } = init_sqlite(&config).await?;
2211
2212 // ── the kernel's database ─────────────────────────────────────────
2213 // Per project, and resolved here rather than in the bridge: a session
2214 // opened without a `store` is the host's to place, exactly as the three
2215 // above are. Not gated behind `sqlite` — the kernel is in every build.
2216 let knl_store = crate::bridge::config::knl_path(&project_root).map_err(BlockError::Runtime)?;
2217 prepare_knl_dir(&knl_store)?;
2218
2219 // Use the script dir derived from the resolved `ScriptSource` for
2220 // `package.path` lookups. For inline / default-agent variants the dir
2221 // falls back to `project_root` (set during source resolution above).
2222 let script_dir = script_dir_pathbuf.to_string_lossy().to_string();
2223
2224 // Precompute values captured by the init closure so we don't need to
2225 // move the full `HostContext` into it (HostContext now holds
2226 // `Arc<AsyncIsle>`, which is available only after `AsyncIsle::spawn`
2227 // returns — classic chicken-and-egg). All bridge registrations run in a
2228 // second pass via `isle.exec` below.
2229 let lib_roots = lib_roots(&project_root);
2230 let lib_paths = package_path_prefix(&lib_roots);
2231
2232 // The seal, checked here because here is where the roots are known and
2233 // still before an Isle exists: a project that would replace the kernel is
2234 // told so instead of running with a kernel that is not the one the Rust
2235 // side was declared against. Same roots, same order, as the require
2236 // registry the init closure builds below.
2237 let require_roots: Vec<PathBuf> = std::iter::once(script_dir_pathbuf.clone())
2238 .chain(lib_roots.iter().cloned())
2239 .collect();
2240 check_sealed_modules(&require_roots)?;
2241
2242 let prompt = prompt_resolved.clone();
2243 let context = context_resolved.clone();
2244
2245 // ── main + handler Isles ──────────────────────────────────────
2246 let SpawnedIsles {
2247 isle,
2248 driver,
2249 handler_isle,
2250 handler_driver,
2251 } = spawn_isles(
2252 &script_name,
2253 &script_dir,
2254 &lib_paths,
2255 &lib_roots,
2256 prompt,
2257 context,
2258 &config.extra_globals,
2259 )
2260 .await?;
2261
2262 // Wire both Isles into McpManager so Lua notification callbacks can be
2263 // dispatched from the rmcp task thread.
2264 // - handler_isle: sampling/createMessage dispatch (exec on handler Isle)
2265 // - main_isle: progress/log notification dispatch (exec on main Isle so
2266 // user callback upvalues are preserved — no bytecode dump/reload needed)
2267 {
2268 let mut mgr = mcp_manager.write().await;
2269 mgr.set_handler_isle(Arc::clone(&handler_isle));
2270 mgr.set_main_isle(Arc::clone(&isle));
2271 }
2272
2273 // ── HostContext + bridge registration ──────────────────────────────
2274 // Wrap the isle in an Arc so `HostContext` can hand it to
2275 // `bridge::bus` (which uses `AsyncIsle::coroutine_call` to invoke Lua
2276 // handlers from the EventBus dispatcher task).
2277 let ctx = HostContext {
2278 project_root,
2279 #[cfg(feature = "mesh")]
2280 mesh_agent,
2281 mcp_manager: Arc::clone(&mcp_manager),
2282 http_client,
2283 #[cfg(feature = "sqlite")]
2284 sql_conn,
2285 #[cfg(feature = "sqlite")]
2286 kv_conn,
2287 #[cfg(feature = "sqlite")]
2288 ts_isle,
2289 isle: Arc::clone(&isle),
2290 handler_isle: Arc::clone(&handler_isle),
2291 bus_tx: bus_tx.clone(),
2292 event_bus: Arc::clone(&event_bus),
2293 fs_snapshots: Default::default(),
2294 knl_logs: crate::knl::Logs::new(),
2295 knl_store,
2296 session_labels: config.session_labels.clone(),
2297 };
2298 // Kept out of the context clone the bridges get: the run loop needs its
2299 // own reference to drain the logs after the VM has gone.
2300 let knl_logs = ctx.knl_logs.clone();
2301
2302 register_bridges(&ctx, &isle, &handler_isle).await?;
2303
2304 // ── Inject host_tools into the Lua tool registry ───────────────
2305 // Done after `bridge::register_all` so `_TOOL_REGISTRY` exists.
2306 inject_host_tools(&isle, &config.host_tools).await?;
2307
2308 drop(_init_span);
2309
2310 // ── Execute ───────────────────────────────────────────────────
2311 // When `shutdown_token` is supplied, race the script future against
2312 // the caller's cancellation signal. On cancel, propagate to the Isle
2313 // via the AsyncTask's cancel token so the debug hook unwinds the Lua
2314 // VM, then continue into the shutdown sequence below (we still want
2315 // to release MCP/mesh handles and join the auto-serve dispatcher
2316 // before returning).
2317 let script_result = execute_script(
2318 &isle,
2319 &script_source,
2320 &script_name,
2321 config.shutdown_token.as_ref(),
2322 )
2323 .await;
2324
2325 // ── auto-serve drain + cancel ─────────────────────────────────
2326 // Let the dispatcher drain events queued by the script, then signal
2327 // shutdown and bound the join. Mirrors `bus.serve`'s grace pattern.
2328 drain_auto_serve(auto_serve_state).await;
2329 if let Some(listener) = http_source {
2330 listener.shutdown().await;
2331 }
2332
2333 // ── Shutdown ──────────────────────────────────────────────────
2334 shutdown(
2335 &mcp_manager,
2336 driver,
2337 handler_driver,
2338 knl_logs,
2339 #[cfg(feature = "sqlite")]
2340 sqlite_drivers,
2341 )
2342 .await?;
2343
2344 script_result
2345}
2346
2347/// mesh → bus source adapter.
2348///
2349/// Implements [`agent_mesh_sdk::RequestHandler`] by packaging every incoming
2350/// mesh request into an [`Event`] with `kind = "mesh"`, pushing it onto the
2351/// bounded `bus_tx` channel, and awaiting the Lua handler's ack over a
2352/// oneshot channel carried inside the event.
2353///
2354/// Error paths (all `tracing::error!`-logged — silent-err-drop policy):
2355///
2356/// | Failure | Return value |
2357/// |---------------------------|----------------------------------------|
2358/// | `bus_tx.send` closed/full | `{"error": "bus channel closed"}` |
2359/// | ack receiver dropped | `{"error": "ack dropped"}` |
2360/// | Lua handler `BlockError` | `{"error": "<handler error>"}` |
2361/// | Handler exceeded 30s | `{"error": "handler timeout"}` |
2362///
2363/// The 30s ack timeout mirrors the client-side timeout on `mesh.request`
2364/// (see `src/bridge/mesh.rs`).
2365#[cfg(feature = "mesh")]
2366struct BusRelayHandler {
2367 tx: mpsc::Sender<Event>,
2368}
2369
2370#[cfg(feature = "mesh")]
2371impl BusRelayHandler {
2372 fn new(tx: mpsc::Sender<Event>) -> Self {
2373 Self { tx }
2374 }
2375}
2376
2377/// Bound used for both the mesh-adapter ack wait and other source timeouts.
2378#[cfg(feature = "mesh")]
2379const BUS_ACK_TIMEOUT: Duration = Duration::from_secs(30);
2380
2381#[cfg(feature = "mesh")]
2382#[async_trait::async_trait]
2383impl agent_mesh_sdk::RequestHandler for BusRelayHandler {
2384 async fn handle(
2385 &self,
2386 from: &agent_mesh_core::identity::AgentId,
2387 payload: &serde_json::Value,
2388 _cancel: agent_mesh_sdk::CancelToken,
2389 ) -> serde_json::Value {
2390 let id = uuid::Uuid::new_v4().to_string();
2391 let meta = serde_json::json!({"from": from.to_string()});
2392 let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
2393 let event = Event {
2394 kind: "mesh".into(),
2395 id: id.clone(),
2396 payload: payload.clone(),
2397 meta,
2398 ack_tx: Some(ack_tx),
2399 };
2400
2401 if let Err(e) = self.tx.send(event).await {
2402 tracing::error!(error = %e, id = %id, "bus channel closed; rejecting mesh request");
2403 return serde_json::json!({"error": "bus channel closed"});
2404 }
2405
2406 match tokio::time::timeout(BUS_ACK_TIMEOUT, ack_rx).await {
2407 Ok(Ok(Ok(v))) => v,
2408 Ok(Ok(Err(e))) => {
2409 tracing::error!(id = %id, error = %e, "mesh handler returned error");
2410 serde_json::json!({"error": e.to_string()})
2411 }
2412 Ok(Err(e)) => {
2413 tracing::error!(id = %id, error = %e, "mesh ack receiver dropped");
2414 serde_json::json!({"error": "ack dropped"})
2415 }
2416 Err(_) => {
2417 tracing::error!(id = %id, timeout_secs = BUS_ACK_TIMEOUT.as_secs(), "mesh handler timeout");
2418 serde_json::json!({"error": "handler timeout"})
2419 }
2420 }
2421 }
2422}
2423
2424#[cfg(test)]
2425mod tests {
2426 use super::*;
2427
2428 /// The sealed set is a promise made in prose as well as in code: README
2429 /// section "Embedded blocks: four layers" names the kernel triple plus
2430 /// `lshape` and its four sub-modules as the layer a project cannot
2431 /// replace. Adding a name here without saying so there leaves a caller
2432 /// reading a document that no longer describes the binary.
2433 #[test]
2434 fn sealed_list_matches_the_readme() {
2435 assert_eq!(
2436 SEALED,
2437 [
2438 "knl",
2439 "knl_adapter",
2440 "knl_types",
2441 "lshape",
2442 "lshape.t",
2443 "lshape.check",
2444 "lshape.reflect",
2445 "lshape.luacats",
2446 ]
2447 .as_slice()
2448 );
2449 }
2450
2451 /// A sealed name that names nothing seals nothing. Every entry must be a
2452 /// module the binary actually carries — `knl_types` is the generated one
2453 /// with no file behind it, hence the extra arm.
2454 #[test]
2455 fn every_sealed_name_is_an_embedded_module() {
2456 for name in SEALED {
2457 let embedded = *name == "knl_types"
2458 || EMBEDDED_BLOCKS
2459 .iter()
2460 .chain(EMBEDDED_LIBS.iter())
2461 .any(|(n, _)| n == name);
2462 assert!(embedded, "sealed name `{name}` is not an embedded module");
2463 }
2464 }
2465
2466 /// The probe has to look where `FsResolver` looks, or it guards paths
2467 /// nobody loads from.
2468 #[test]
2469 fn require_candidates_match_the_fs_resolver_layout() {
2470 let root = Path::new("/p/blocks");
2471
2472 assert_eq!(
2473 require_candidates(root, "knl"),
2474 [
2475 PathBuf::from("/p/blocks/knl.lua"),
2476 PathBuf::from("/p/blocks/knl/init.lua"),
2477 ]
2478 );
2479 // A dotted name is a path, the same way `require` reads it.
2480 assert_eq!(
2481 require_candidates(root, "lshape.t"),
2482 [
2483 PathBuf::from("/p/blocks/lshape/t.lua"),
2484 PathBuf::from("/p/blocks/lshape/t/init.lua"),
2485 ]
2486 );
2487 }
2488
2489 #[test]
2490 fn an_empty_root_is_not_a_shadow() {
2491 let tmp = tempfile::tempdir().expect("tempdir");
2492 check_sealed_modules(&[tmp.path().to_path_buf()]).expect("nothing to seal against");
2493 }
2494
2495 #[test]
2496 fn a_shadowing_file_is_refused_by_name_and_path() {
2497 let tmp = tempfile::tempdir().expect("tempdir");
2498 let knl = tmp.path().join("knl");
2499 std::fs::create_dir_all(&knl).expect("mkdir");
2500 std::fs::write(knl.join("init.lua"), "return {}").expect("write");
2501
2502 let err = check_sealed_modules(&[tmp.path().to_path_buf()])
2503 .expect_err("a sealed module was shadowed");
2504 let msg = err.to_string();
2505
2506 assert!(msg.contains("knl"), "the module is not named: {msg}");
2507 assert!(
2508 msg.contains(&knl.join("init.lua").display().to_string()),
2509 "the file is not named: {msg}"
2510 );
2511 assert!(
2512 msg.contains("AGENT_BLOCK_UNSEAL"),
2513 "the escape hatch is not mentioned: {msg}"
2514 );
2515 }
2516
2517 /// The flat form (`lshape/t.lua`) is as much a replacement as the
2518 /// directory form, and sub-modules are sealed individually.
2519 #[test]
2520 fn a_shadowing_sub_module_is_refused() {
2521 let tmp = tempfile::tempdir().expect("tempdir");
2522 let lshape = tmp.path().join("lshape");
2523 std::fs::create_dir_all(&lshape).expect("mkdir");
2524 std::fs::write(lshape.join("t.lua"), "return {}").expect("write");
2525
2526 let err = check_sealed_modules(&[tmp.path().to_path_buf()])
2527 .expect_err("a sealed sub-module was shadowed");
2528 assert!(
2529 err.to_string().contains("lshape.t"),
2530 "the sub-module is not named: {err}"
2531 );
2532 }
2533
2534 /// Shadowing an unsealed block is the supported way to change one, so it
2535 /// must survive the probe untouched.
2536 #[test]
2537 fn shadowing_an_unsealed_block_is_allowed() {
2538 let tmp = tempfile::tempdir().expect("tempdir");
2539 let agent = tmp.path().join("agent");
2540 std::fs::create_dir_all(&agent).expect("mkdir");
2541 std::fs::write(agent.join("init.lua"), "return {}").expect("write");
2542
2543 check_sealed_modules(&[tmp.path().to_path_buf()]).expect("`agent` is not sealed");
2544 }
2545
2546 /// The one contract with `blocks/lib/job/init.lua`: a raise carrying
2547 /// `job.defer: ` is the block not starting, with the reason after the
2548 /// prefix, however the runtime wrapped it.
2549 #[test]
2550 fn a_defer_raise_is_deferred_with_its_reason() {
2551 let wrapped = "runtime error: [string \"drain\"]:12: job.defer: llm endpoint unreachable\nstack traceback:\n\t...";
2552 match script_failure(wrapped) {
2553 BlockError::Deferred(reason) => assert_eq!(reason, "llm endpoint unreachable"),
2554 other => panic!("expected Deferred, got {other:?}"),
2555 }
2556 match script_failure("runtime error: attempt to index nil") {
2557 BlockError::Script(text) => assert!(text.contains("attempt to index nil")),
2558 other => panic!("expected Script, got {other:?}"),
2559 }
2560 }
2561}