Skip to main content

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