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