Skip to main content

agent_block_core/
host.rs

1//! Host — the thin Rust shell that wires up Lua VM, Mesh, HTTP, and MCP.
2//!
3//! # Responsibilities
4//!
5//! 1. Spawn an mlua-isle `AsyncIsle` (dedicated Lua VM thread with coroutine support)
6//! 2. Optionally connect to agent-mesh relay
7//! 3. Initialize the MCP manager for stdio-based MCP server connections
8//! 4. Inject all Lua stdlib bridges (`mesh.*`, `http.*`, `sh.*`, `tool.*`, `log.*`, `mcp.*`)
9//! 5. Execute the user-provided Lua script via `coroutine_eval` (async-aware)
10//! 6. Graceful shutdown (Isle + MCP servers + mesh)
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16use tokio::sync::{mpsc, RwLock};
17
18use mlua_isle::{AsyncIsle, AsyncIsleDriver};
19use tracing::{info, info_span, warn};
20
21use crate::bridge;
22use crate::bus::{Event, EventBus, Handler};
23use agent_block_mcp::McpManager;
24use agent_block_types::error::{BlockError, BlockResult};
25use tokio_util::sync::CancellationToken;
26
27/// Embedded Lua sources for blocks/ StdPkg modules.
28/// These are baked into the binary at compile time so `cargo install` works
29/// without any extra file distribution.
30const EMBEDDED_BLOCKS: &[(&str, &str)] = &[
31    ("agent", include_str!("../blocks/agent/init.lua")),
32    ("session", include_str!("../blocks/session/init.lua")),
33    (
34        "compile_loop",
35        include_str!("../blocks/compile_loop/init.lua"),
36    ),
37];
38
39/// Embedded default agent invoker used by [`ScriptSource::DefaultAgent`].
40///
41/// Runs the StdPkg `agent` module with `_PROMPT` / `_CONTEXT` injected and
42/// emits the result on the EventBus. The emit kind is `"_"` — a neutral
43/// label with no SDK-side meaning. The result is intended to be received
44/// via [`BlockConfig::host_handler`] (the kind-agnostic single sink); the
45/// literal label is irrelevant to SDK consumers.
46const DEFAULT_AGENT_INVOKER: &str = r#"
47local agent = require("agent")
48local r = agent.run({
49    prompt = _PROMPT,
50    system = _CONTEXT,
51})
52bus.emit("_", r)
53"#;
54
55/// How the Lua script source for `run()` is supplied.
56///
57/// `Path` matches the CLI form (`agent-block -s <path>`), reading from
58/// the filesystem at start. `Inline` lets SDK consumers pass a script
59/// they hold in memory (compile-time `include_str!`, dynamically built
60/// string, etc.) without writing it to a tempfile. `DefaultAgent` uses
61/// an embedded invoker that runs the StdPkg `agent` module with the
62/// caller-supplied prompt/context and emits the result via
63/// `bus.emit("agent_result", ...)`.
64#[derive(Debug, Clone)]
65pub enum ScriptSource {
66    /// Read the script from a filesystem path at start.
67    Path(PathBuf),
68    /// Use the supplied source code directly.
69    Inline {
70        /// Lua source code.
71        source: String,
72        /// Display name used in tracing, error messages, and the Lua
73        /// `_SCRIPT_NAME` global (e.g. `"agent_invoker.lua"`).
74        name: String,
75    },
76    /// Use the embedded default agent invoker. `prompt` / `context`
77    /// are forwarded as `_PROMPT` / `_CONTEXT` Lua globals and the
78    /// agent result is emitted on the EventBus under a neutral label
79    /// (`"_"`). SDK consumers should pair this with
80    /// [`BlockConfig::host_handler`] (the kind-agnostic single sink)
81    /// and `auto_serve_bus = true`. The emit-kind is intentionally
82    /// meaningless; consumers that need string-keyed routing should
83    /// supply [`ScriptSource::Inline`] with their own invoker.
84    DefaultAgent,
85}
86
87/// How a string payload (prompt / system context) is supplied.
88///
89/// `Inline` is the literal string variant (CLI `--prompt` / `--context`).
90/// `File` reads the contents from disk at `run()` start (CLI
91/// `--prompt-file` / `--context-file`).
92#[derive(Debug, Clone)]
93pub enum PromptSource {
94    /// Literal string.
95    Inline(String),
96    /// Filesystem path; contents are read at `run()` start.
97    File(PathBuf),
98}
99
100/// How the Ed25519 mesh identity secret key is supplied.
101///
102/// `Inline` is a 64-hex literal. `Env` reads the named environment
103/// variable at `run()` start (CLI default uses
104/// `AGENT_BLOCK_MESH_SECRET_KEY`). Absence of any `SecretKeySource`
105/// (i.e. `BlockConfig.secret_key = None`) causes a random keypair to
106/// be generated, matching the prior behavior.
107#[derive(Debug, Clone)]
108pub enum SecretKeySource {
109    /// 64-character hex literal.
110    Inline(String),
111    /// Environment variable name to read at start.
112    Env(String),
113}
114
115/// Async handler invoked when the LLM (or a Lua call to
116/// `tool.call(name, ...)`) targets a Rust-implemented tool supplied via
117/// [`BlockConfig::host_tools`].
118///
119/// `input` arrives as a `serde_json::Value` (converted from Lua before
120/// the handler is invoked). The returned value is converted back to a
121/// Lua value and delivered to the caller. Errors are propagated as
122/// `LuaError::external` (visible inside the script) and as `BlockError`
123/// on the Rust side.
124#[async_trait::async_trait]
125pub trait ToolHandler: Send + Sync + 'static {
126    async fn call(&self, input: serde_json::Value) -> Result<serde_json::Value, BlockError>;
127}
128
129/// Declarative spec for a Rust-implemented tool injected into the Lua
130/// tool registry before the user script runs. The resulting entry is
131/// indistinguishable from a Lua-defined tool from the script's view:
132/// `tool.call("<name>", input)`, `agent.run({ ... })` tool dispatch,
133/// and `tool.schema()` enumeration all work uniformly.
134#[derive(Clone)]
135pub struct HostToolSpec {
136    /// Tool name. Becomes the routing key in `_TOOL_REGISTRY` and the
137    /// `name` field exposed by `tool.schema()` (Anthropic tool spec).
138    pub name: String,
139    /// Free-form description shown to the LLM. Becomes the
140    /// `description` field of the Anthropic tool spec.
141    pub description: String,
142    /// Input schema (Anthropic-compatible JSON Schema object).
143    pub input_schema: serde_json::Value,
144    /// Optional group label for [`agent.run`'s `tool_groups`] filter
145    /// and for [`BlockConfig::tool_policy`] (planned).
146    pub group: Option<String>,
147    /// Rust callback dispatched on every invocation.
148    pub handler: Arc<dyn ToolHandler>,
149}
150
151impl std::fmt::Debug for HostToolSpec {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("HostToolSpec")
154            .field("name", &self.name)
155            .field("description", &self.description)
156            .field("input_schema", &self.input_schema)
157            .field("group", &self.group)
158            .field("handler", &"<dyn ToolHandler>")
159            .finish()
160    }
161}
162
163/// Snapshot of a tool that a given [`BlockConfig`] will (statically)
164/// expose to the LLM. Produced by [`inspect_tools`] without running
165/// the script. MCP server tools are *not* included because they are
166/// only known after the MCP `initialize` handshake completes; callers
167/// that need that view should run the script and call `tool.schema()`
168/// from Lua.
169#[derive(Debug, Clone)]
170pub struct ToolMeta {
171    pub name: String,
172    pub description: String,
173    pub group: Option<String>,
174    pub source: ToolSource,
175}
176
177/// Origin of a tool listed by [`inspect_tools`].
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum ToolSource {
180    /// Supplied via [`BlockConfig::host_tools`] (Rust-implemented).
181    HostRust,
182    /// Embedded StdPkg block (`agent`, `compile_loop`, …) — discovered
183    /// statically from [`EMBEDDED_BLOCKS`]. Note: not every embedded
184    /// block exposes a registered tool; this entry simply records that
185    /// the module is available via `require(...)`.
186    EmbeddedBlock,
187}
188
189/// Inspect the tools a [`BlockConfig`] will expose to the LLM without
190/// actually running the script. Returns the merged list of
191/// `host_tools` (declared in the config) and embedded-block sources.
192///
193/// MCP server tools are deliberately omitted — they only become known
194/// after the MCP `initialize` handshake. Use `tool.schema()` from
195/// inside the running script for that view.
196pub fn inspect_tools(config: &BlockConfig) -> Vec<ToolMeta> {
197    let mut out = Vec::new();
198    for t in &config.host_tools {
199        out.push(ToolMeta {
200            name: t.name.clone(),
201            description: t.description.clone(),
202            group: t.group.clone(),
203            source: ToolSource::HostRust,
204        });
205    }
206    for (name, _src) in EMBEDDED_BLOCKS {
207        out.push(ToolMeta {
208            name: (*name).to_string(),
209            description: format!("Embedded StdPkg block (require(\"{name}\"))"),
210            group: None,
211            source: ToolSource::EmbeddedBlock,
212        });
213    }
214    out
215}
216
217/// Build the `blocks/` portion of `package.path` from filesystem locations.
218///
219/// Priority (highest first):
220/// 1. `project_root/blocks/` — user-customisable, overrides embedded StdPkg
221/// 2. `exe_dir/blocks/`      — development hot-reload (next to the binary)
222///
223/// Returns a semicolon-terminated string ready to prepend to `package.path`,
224/// or an empty string when no `blocks/` directories are found.
225fn build_blocks_path(project_root: &Path) -> String {
226    let mut out = String::new();
227
228    // 1. project_root/blocks/
229    let project_blocks = project_root.join("blocks");
230    if project_blocks.is_dir() {
231        let pb = project_blocks.to_string_lossy();
232        out.push_str(&format!("{pb}/?.lua;{pb}/?/init.lua;"));
233    }
234
235    // 2. exe_dir/blocks/
236    match std::env::current_exe() {
237        Ok(exe) => {
238            if let Some(exe_dir) = exe.parent() {
239                let exe_blocks = exe_dir.join("blocks");
240                if exe_blocks.is_dir() {
241                    let eb = exe_blocks.to_string_lossy();
242                    out.push_str(&format!("{eb}/?.lua;{eb}/?/init.lua;"));
243                }
244            }
245        }
246        Err(e) => {
247            warn!(error = %e, "current_exe() failed; skipping exe_dir/blocks/ from package.path");
248        }
249    }
250
251    out
252}
253
254/// Full configuration for a single [`run`] execution.
255///
256/// # Construction
257///
258/// Prefer [`BlockConfig::builder`] over struct-literal construction. The
259/// struct is `#[non_exhaustive]`, so crates outside `agent-block-core`
260/// cannot build it with a `BlockConfig { .. }` literal; the builder is the
261/// supported, forward-compatible path. New fields are added with sensible
262/// defaults, so existing builder call sites keep compiling when the config
263/// surface grows.
264///
265/// All fields remain `pub` for reading (`config.project_root`, etc.); only
266/// the literal-construction form is gated by `#[non_exhaustive]`.
267///
268/// ```no_run
269/// use agent_block_core::BlockConfig;
270/// use agent_block_core::host::ScriptSource;
271/// use std::path::PathBuf;
272///
273/// let config = BlockConfig::builder(
274///     ScriptSource::Path(PathBuf::from("agent.lua")),
275///     PathBuf::from("."),
276/// )
277/// .auto_serve_bus(true)
278/// .build();
279/// ```
280#[non_exhaustive]
281pub struct BlockConfig {
282    /// Lua script to execute. See [`ScriptSource`] for the supported
283    /// shapes (filesystem path / inline source / embedded default
284    /// agent invoker).
285    pub script: ScriptSource,
286    pub project_root: PathBuf,
287    pub relay_url: Option<String>,
288    /// Ed25519 secret key for mesh identity. See [`SecretKeySource`]
289    /// for the supported shapes (inline 64-hex / environment variable).
290    /// `None` generates a random keypair. Required to talk to
291    /// registry/ACL-gated hosted meshes.
292    pub secret_key: Option<SecretKeySource>,
293    /// Per-RPC timeout for every MCP round-trip (connect / list / call).
294    /// Defaults to [`agent_block_mcp::DEFAULT_RPC_TIMEOUT`].
295    pub mcp_rpc_timeout: Duration,
296    /// Prompt payload injected as `_PROMPT` Lua global. See
297    /// [`PromptSource`] for the supported shapes. `None` leaves the
298    /// global unset.
299    pub prompt: Option<PromptSource>,
300    /// Context payload injected as `_CONTEXT` Lua global (typically
301    /// the system prompt). Same shape rules as [`Self::prompt`].
302    pub context: Option<PromptSource>,
303    /// Host-side Rust handlers pre-installed on the EventBus before the user
304    /// script starts. Each entry registers `handler` against `kind` via
305    /// [`EventBus::on`], so a script-side `bus.emit(kind, payload)` is
306    /// captured by the Rust handler rather than dispatched to a Lua function.
307    ///
308    /// Intended for SDK consumers that embed `agent-block-core` and need to
309    /// receive script output programmatically (e.g. a Spawner adapter that
310    /// turns LLM script output into a typed `WorkerResult`). Lua-side
311    /// `bus.on(kind, fn)` registrations layered on top of the handler Isle
312    /// are still possible, but the EventBus dispatches a single handler per
313    /// `kind` (last-write-wins), so host-side and Lua-side registrations on
314    /// the same `kind` collide; choose one side per routing key.
315    ///
316    /// Defaults to an empty map (no host handlers).
317    pub host_handlers: HashMap<String, Arc<dyn Handler>>,
318    /// Single host-side Rust handler that catches every event regardless
319    /// of `kind`. Internally registered via [`EventBus::on_any`], so it
320    /// acts as a fallback when no entry in [`Self::host_handlers`]
321    /// matches the incoming `kind`.
322    ///
323    /// This is the SDK-embed 1-shot sink: SDK consumers do not need to
324    /// invent or coordinate a string `kind` between the Lua script and
325    /// their Rust code. The agent invoker's emit-kind is irrelevant —
326    /// the handler receives every event.
327    ///
328    /// Use this when you want a single Rust handler to receive results
329    /// (typical embedded use). Use [`Self::host_handlers`] instead when
330    /// you actually need string-keyed routing (multi-source / multi-
331    /// handler dispatch). The two may coexist: kind-specific handlers
332    /// in `host_handlers` take precedence, and this single handler is
333    /// the fallback for unmatched kinds.
334    ///
335    /// Defaults to `None`.
336    pub host_handler: Option<Arc<dyn Handler>>,
337    /// Rust-implemented tools injected into the Lua tool registry
338    /// before the user script runs. Each entry becomes
339    /// indistinguishable from a Lua-defined tool: it is discoverable
340    /// via `tool.list()` / `tool.schema()`, dispatchable via
341    /// `tool.call(name, input)`, and visible to `agent.run`'s LLM
342    /// function-calling.
343    ///
344    /// SDK consumers can use this to expose Rust capabilities
345    /// (database lookups, business logic, etc.) to the LLM without
346    /// writing any Lua. See [`HostToolSpec`] and [`ToolHandler`].
347    ///
348    /// Defaults to an empty list.
349    pub host_tools: Vec<HostToolSpec>,
350    /// Optional custom `reqwest::Client` for the `http.*` Lua bridge
351    /// and any other in-process HTTP traffic. SDK consumers can wire
352    /// in their own TLS roots, proxy, default headers, connection
353    /// pool tuning, etc.
354    ///
355    /// `None` falls back to `reqwest::Client::new()` with default
356    /// settings (legacy behavior).
357    pub http_client: Option<reqwest::Client>,
358    /// Override path for the `std.sql` SQLite database file. `None`
359    /// reads the `AGENT_BLOCK_SQL_PATH` env var (CLI default), or
360    /// falls back to `{base_dir}/db.sqlite`. Pass `Some(":memory:")`
361    /// for an in-memory DB (useful for tests / isolation).
362    pub sql_path: Option<PathBuf>,
363    /// Override path for the `std.kv` SQLite database file. Same
364    /// semantics as [`Self::sql_path`].
365    pub kv_path: Option<PathBuf>,
366    /// Override path for the `std.ts` SQLite database file. Same
367    /// semantics as [`Self::sql_path`].
368    pub ts_path: Option<PathBuf>,
369    /// Extra Lua globals injected into both the main Isle and the
370    /// handler Isle before the user script runs. Each entry
371    /// `(name, value)` results in `_G[name] = json_to_lua(value)`.
372    ///
373    /// Use this to parameterize an inline script from Rust without
374    /// baking the values into the Lua source (`_USER_ID`,
375    /// `_TENANT`, `_FEATURE_FLAGS`, etc.). Keys must be valid Lua
376    /// identifiers; values are any `serde_json::Value`.
377    ///
378    /// `_PROMPT`, `_CONTEXT`, and `_SCRIPT_NAME` are reserved
379    /// (managed by other `BlockConfig` fields); colliding with them
380    /// silently overrides those defaults — use with care.
381    pub extra_globals: HashMap<String, serde_json::Value>,
382    /// When `true`, the EventBus dispatcher loop is driven in the background
383    /// for the duration of the script and shut down gracefully after the
384    /// script completes. Required for SDK-embed callers that supply
385    /// [`Self::host_handlers`] and need `bus.emit(kind, payload)` events
386    /// emitted from the script to actually reach those handlers without
387    /// requiring the script to call `bus.serve()` (which blocks on
388    /// SIGTERM / Ctrl+C and never returns under programmatic embedding).
389    ///
390    /// After the script finishes, the dispatcher is given a grace window
391    /// (`AGENT_BLOCK_TASK_GRACE_MS`, default 1000ms) to drain queued events
392    /// and finish any in-flight handler, then is cancelled.
393    ///
394    /// Mutually exclusive with Lua-side `bus.serve()`: enabling this flag
395    /// takes ownership of the EventBus before the script runs, so a script
396    /// that calls `bus.on(...)` followed by `bus.serve()` will error
397    /// ("bus.serve() has already taken ownership"). Use this flag when the
398    /// script's sole purpose is to push events to host handlers.
399    ///
400    /// Defaults to `false` (legacy behavior: dispatcher only runs when the
401    /// script calls `bus.serve()`).
402    pub auto_serve_bus: bool,
403    /// Optional caller-supplied cancellation token. When cancelled, the
404    /// in-flight script is interrupted via the Isle's debug-hook cancel
405    /// path, the auto-serve dispatcher (if any) is shut down, and `run()`
406    /// returns `Err(BlockError::Cancelled)`.
407    ///
408    /// Intended for SDK consumers that spawn `run()` as a tokio task and
409    /// need an out-of-band abort signal (timeouts, parent-task cancellation
410    /// propagation, user-driven stop). The token is observed across the
411    /// `coroutine_eval` await; once cancellation propagates, the shutdown
412    /// sequence (MCP disconnect, Isle drivers, auto-serve dispatcher)
413    /// still runs so file descriptors and remote handles are released.
414    ///
415    /// Defaults to `None` (legacy behavior: `run()` only completes when
416    /// the script returns naturally).
417    pub shutdown_token: Option<CancellationToken>,
418}
419
420impl BlockConfig {
421    /// Start building a [`BlockConfig`] with the two semantically required
422    /// inputs supplied up front.
423    ///
424    /// `script` selects what Lua source to execute — there is no meaningful
425    /// default, since a run has nothing to do without a script. `project_root`
426    /// anchors `.env` loading, inline-script directory resolution, and the
427    /// default working directory handed to spawned MCP servers.
428    ///
429    /// Every other field starts at the default documented on the matching
430    /// [`BlockConfig`] field and is overridden through the chainable
431    /// [`BlockConfigBuilder`] setters. This is the recommended construction
432    /// path for SDK embedders: `BlockConfig` is `#[non_exhaustive]`, so
433    /// struct-literal construction is unavailable to downstream crates and new
434    /// fields can be added without breaking existing builder call sites.
435    ///
436    /// ```no_run
437    /// use agent_block_core::BlockConfig;
438    /// use agent_block_core::host::ScriptSource;
439    /// use std::path::PathBuf;
440    ///
441    /// let config = BlockConfig::builder(
442    ///     ScriptSource::Path(PathBuf::from("agent.lua")),
443    ///     PathBuf::from("."),
444    /// )
445    /// .auto_serve_bus(true)
446    /// .build();
447    /// ```
448    pub fn builder(script: ScriptSource, project_root: PathBuf) -> BlockConfigBuilder {
449        BlockConfigBuilder::new(script, project_root)
450    }
451}
452
453/// Chainable builder for [`BlockConfig`], created via
454/// [`BlockConfig::builder`].
455///
456/// Each setter returns `self` for fluent chaining. Setters for `Option<_>`
457/// fields take the inner value and wrap it in `Some` internally, so callers
458/// pass e.g. `.prompt(PromptSource::Inline(..))` rather than an `Option`.
459/// Fields left untouched keep the defaults documented on the corresponding
460/// [`BlockConfig`] field.
461///
462/// Because [`BlockConfig`] is `#[non_exhaustive]`, this builder is the only
463/// supported way for crates outside `agent-block-core` to construct one, and
464/// it stays source-compatible as new config fields are introduced.
465pub struct BlockConfigBuilder {
466    script: ScriptSource,
467    project_root: PathBuf,
468    relay_url: Option<String>,
469    secret_key: Option<SecretKeySource>,
470    mcp_rpc_timeout: Duration,
471    prompt: Option<PromptSource>,
472    context: Option<PromptSource>,
473    host_handlers: HashMap<String, Arc<dyn Handler>>,
474    host_handler: Option<Arc<dyn Handler>>,
475    host_tools: Vec<HostToolSpec>,
476    http_client: Option<reqwest::Client>,
477    sql_path: Option<PathBuf>,
478    kv_path: Option<PathBuf>,
479    ts_path: Option<PathBuf>,
480    extra_globals: HashMap<String, serde_json::Value>,
481    auto_serve_bus: bool,
482    shutdown_token: Option<CancellationToken>,
483}
484
485impl BlockConfigBuilder {
486    fn new(script: ScriptSource, project_root: PathBuf) -> Self {
487        Self {
488            script,
489            project_root,
490            relay_url: None,
491            secret_key: None,
492            mcp_rpc_timeout: agent_block_mcp::DEFAULT_RPC_TIMEOUT,
493            prompt: None,
494            context: None,
495            host_handlers: HashMap::new(),
496            host_handler: None,
497            host_tools: Vec::new(),
498            http_client: None,
499            sql_path: None,
500            kv_path: None,
501            ts_path: None,
502            extra_globals: HashMap::new(),
503            auto_serve_bus: false,
504            shutdown_token: None,
505        }
506    }
507
508    /// Override the Lua script to execute (`BlockConfig::script`).
509    pub fn script(mut self, script: ScriptSource) -> Self {
510        self.script = script;
511        self
512    }
513
514    /// Override the project root (`BlockConfig::project_root`).
515    pub fn project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
516        self.project_root = project_root.into();
517        self
518    }
519
520    /// Set the mesh relay URL (`BlockConfig::relay_url`). Defaults to `None`
521    /// (mesh disabled).
522    pub fn relay_url(mut self, relay_url: impl Into<String>) -> Self {
523        self.relay_url = Some(relay_url.into());
524        self
525    }
526
527    /// Set the mesh identity secret key source (`BlockConfig::secret_key`).
528    /// Defaults to `None` (random keypair).
529    pub fn secret_key(mut self, secret_key: SecretKeySource) -> Self {
530        self.secret_key = Some(secret_key);
531        self
532    }
533
534    /// Override the per-RPC MCP timeout (`BlockConfig::mcp_rpc_timeout`).
535    /// Defaults to [`agent_block_mcp::DEFAULT_RPC_TIMEOUT`].
536    pub fn mcp_rpc_timeout(mut self, mcp_rpc_timeout: Duration) -> Self {
537        self.mcp_rpc_timeout = mcp_rpc_timeout;
538        self
539    }
540
541    /// Set the prompt payload injected as `_PROMPT` (`BlockConfig::prompt`).
542    /// Defaults to `None`.
543    pub fn prompt(mut self, prompt: PromptSource) -> Self {
544        self.prompt = Some(prompt);
545        self
546    }
547
548    /// Set the context payload injected as `_CONTEXT`
549    /// (`BlockConfig::context`). Defaults to `None`.
550    pub fn context(mut self, context: PromptSource) -> Self {
551        self.context = Some(context);
552        self
553    }
554
555    /// Set the kind-keyed host-side handlers (`BlockConfig::host_handlers`).
556    /// Defaults to an empty map.
557    pub fn host_handlers(mut self, host_handlers: HashMap<String, Arc<dyn Handler>>) -> Self {
558        self.host_handlers = host_handlers;
559        self
560    }
561
562    /// Set the kind-agnostic fallback host handler
563    /// (`BlockConfig::host_handler`). Defaults to `None`.
564    pub fn host_handler(mut self, host_handler: Arc<dyn Handler>) -> Self {
565        self.host_handler = Some(host_handler);
566        self
567    }
568
569    /// Set the Rust-implemented tools injected into the Lua registry
570    /// (`BlockConfig::host_tools`). Defaults to an empty list.
571    pub fn host_tools(mut self, host_tools: Vec<HostToolSpec>) -> Self {
572        self.host_tools = host_tools;
573        self
574    }
575
576    /// Set a custom `reqwest::Client` for the `http.*` bridge
577    /// (`BlockConfig::http_client`). Defaults to `None`.
578    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
579        self.http_client = Some(http_client);
580        self
581    }
582
583    /// Override the `std.sql` database path (`BlockConfig::sql_path`).
584    /// Defaults to `None`.
585    pub fn sql_path(mut self, sql_path: impl Into<PathBuf>) -> Self {
586        self.sql_path = Some(sql_path.into());
587        self
588    }
589
590    /// Override the `std.kv` database path (`BlockConfig::kv_path`).
591    /// Defaults to `None`.
592    pub fn kv_path(mut self, kv_path: impl Into<PathBuf>) -> Self {
593        self.kv_path = Some(kv_path.into());
594        self
595    }
596
597    /// Override the `std.ts` database path (`BlockConfig::ts_path`).
598    /// Defaults to `None`.
599    pub fn ts_path(mut self, ts_path: impl Into<PathBuf>) -> Self {
600        self.ts_path = Some(ts_path.into());
601        self
602    }
603
604    /// Set the extra Lua globals injected before the script runs
605    /// (`BlockConfig::extra_globals`). Defaults to an empty map.
606    pub fn extra_globals(mut self, extra_globals: HashMap<String, serde_json::Value>) -> Self {
607        self.extra_globals = extra_globals;
608        self
609    }
610
611    /// Enable or disable the background EventBus dispatcher
612    /// (`BlockConfig::auto_serve_bus`). Defaults to `false`.
613    pub fn auto_serve_bus(mut self, auto_serve_bus: bool) -> Self {
614        self.auto_serve_bus = auto_serve_bus;
615        self
616    }
617
618    /// Set the caller-supplied cancellation token
619    /// (`BlockConfig::shutdown_token`). Defaults to `None`.
620    pub fn shutdown_token(mut self, shutdown_token: CancellationToken) -> Self {
621        self.shutdown_token = Some(shutdown_token);
622        self
623    }
624
625    /// Finalize the builder into a [`BlockConfig`].
626    pub fn build(self) -> BlockConfig {
627        BlockConfig {
628            script: self.script,
629            project_root: self.project_root,
630            relay_url: self.relay_url,
631            secret_key: self.secret_key,
632            mcp_rpc_timeout: self.mcp_rpc_timeout,
633            prompt: self.prompt,
634            context: self.context,
635            host_handlers: self.host_handlers,
636            host_handler: self.host_handler,
637            host_tools: self.host_tools,
638            http_client: self.http_client,
639            sql_path: self.sql_path,
640            kv_path: self.kv_path,
641            ts_path: self.ts_path,
642            extra_globals: self.extra_globals,
643            auto_serve_bus: self.auto_serve_bus,
644            shutdown_token: self.shutdown_token,
645        }
646    }
647}
648
649/// Shared context passed into Lua bridge functions.
650#[derive(Clone)]
651pub struct HostContext {
652    pub project_root: PathBuf,
653    /// Connected mesh agent (present only when the `mesh` feature is enabled
654    /// and a relay URL was supplied).
655    #[cfg(feature = "mesh")]
656    pub mesh_agent: Option<Arc<agent_mesh_sdk::MeshAgent>>,
657    pub mcp_manager: Arc<RwLock<McpManager>>,
658    /// Shared async HTTP client for `http.*` bridge.
659    pub http_client: reqwest::Client,
660    /// Shared SQLite connection for `sql.*` bridge (user tables).
661    #[cfg(feature = "sqlite")]
662    pub sql_conn: Arc<Mutex<rusqlite::Connection>>,
663    /// Interrupt handle for the sql connection.
664    /// Used to cancel in-flight queries on timeout (see `bridge/sql.rs`).
665    #[cfg(feature = "sqlite")]
666    pub sql_interrupt: Arc<rusqlite::InterruptHandle>,
667    /// Shared SQLite connection for `kv.*` bridge (`__kv` table only).
668    /// Separate from sql_conn so KV scratch state and user SQL data don't
669    /// share WAL, page cache, or backup lifecycle.
670    #[cfg(feature = "sqlite")]
671    pub kv_conn: Arc<Mutex<rusqlite::Connection>>,
672    /// Interrupt handle for the kv connection.
673    #[cfg(feature = "sqlite")]
674    pub kv_interrupt: Arc<rusqlite::InterruptHandle>,
675    /// Shared SQLite connection for `ts.*` bridge (TSDB — time-series table).
676    /// Separate DB file so TSDB WAL does not share page cache with kv/sql.
677    #[cfg(feature = "sqlite")]
678    pub ts_conn: Arc<Mutex<rusqlite::Connection>>,
679    /// Interrupt handle for the ts connection.
680    /// Used by `bridge::ts` to cancel in-flight queries on timeout (Subtask 2).
681    #[allow(dead_code)]
682    #[cfg(feature = "sqlite")]
683    pub ts_interrupt: Arc<rusqlite::InterruptHandle>,
684    /// Async handle to the main Isle Lua VM that runs the user script via
685    /// `coroutine_eval`. After Subtask 2, `bridge::bus` no longer dispatches
686    /// handlers against this Isle; handlers live on `handler_isle` instead.
687    /// The field is retained because bridge code still keyed to the main
688    /// Isle (future `coroutine_call` back-edges, introspection APIs) may
689    /// need it, and removing it would force another HostContext reshape.
690    #[allow(dead_code)]
691    pub isle: Arc<AsyncIsle>,
692    /// Dedicated Isle for EventBus handler execution. Lua handlers
693    /// registered via `bus.on` / `bus.on_any` run here so that CPU-bound
694    /// handler code does not occupy the main Isle's LocalSet and block
695    /// grace timers / shutdown wakers on the main VM side.
696    ///
697    /// Used by `bridge::bus` to forward handler bytecode
698    /// (`Function::dump(true)` → `handler_isle.exec(...)`) and by
699    /// [`LuaHandler::call`](crate::bridge::bus) to dispatch via
700    /// `coroutine_call("__bus_dispatch", ...)`.
701    pub handler_isle: Arc<AsyncIsle>,
702    /// Ingress sender for the EventBus. Adapters (mesh / webhook / …)
703    /// clone this and push `Event`s. The ST3 mesh adapter captures its own
704    /// clone at `MeshAgent::connect` time, so the field itself is not read
705    /// elsewhere in the ST3 cut — kept `pub` for ST4+ adapter wiring.
706    #[allow(dead_code)]
707    pub bus_tx: mpsc::Sender<Event>,
708    /// Mutex-wrapped `Option<EventBus>` so `bus.on` / `bus.on_any` can lock
709    /// briefly from sync Lua context, and `bus.serve` can `Option::take`
710    /// ownership before entering the long-lived `run()` await (avoiding the
711    /// await-holding-lock anti-pattern on a `std::sync::Mutex`).
712    pub event_bus: Arc<Mutex<Option<EventBus>>>,
713}
714
715impl HostContext {
716    /// Agent id of the connected mesh agent, if any.
717    ///
718    /// Returns `Some(agent_id)` when the `mesh` feature is enabled and a mesh
719    /// agent is connected. Keeps the `#[cfg(feature = "mesh")]` gating out of
720    /// bridge call sites that only need a fallback agent-id string.
721    #[cfg(feature = "mesh")]
722    pub fn mesh_agent_id(&self) -> Option<String> {
723        self.mesh_agent.as_ref().map(|a| a.agent_id().to_string())
724    }
725
726    /// See the `mesh`-enabled variant. Without the `mesh` feature there is no
727    /// mesh agent, so this is always `None`.
728    #[cfg(not(feature = "mesh"))]
729    pub fn mesh_agent_id(&self) -> Option<String> {
730        None
731    }
732}
733
734/// Open a SQLite connection at `path` (or `:memory:`) and apply the shared
735/// pragmas driven by ENV (`journal_mode`, `busy_timeout`). Returns the
736/// connection wrapped in Arc<Mutex<_>> together with its interrupt handle.
737///
738/// `label` is used only for the init log line (`sql` / `kv`) so that the two
739/// databases are distinguishable in tracing output.
740#[cfg(feature = "sqlite")]
741fn open_sqlite(
742    path: &Path,
743    label: &'static str,
744) -> BlockResult<(
745    Arc<Mutex<rusqlite::Connection>>,
746    Arc<rusqlite::InterruptHandle>,
747)> {
748    let is_memory = crate::bridge::config::is_memory_sql(path);
749    if !is_memory {
750        if let Some(parent) = path.parent() {
751            std::fs::create_dir_all(parent)
752                .map_err(|e| BlockError::Runtime(format!("{label} dir create: {e}")))?;
753        }
754    }
755    let conn = rusqlite::Connection::open(path)
756        .map_err(|e| BlockError::Runtime(format!("sqlite open {}: {e}", path.display())))?;
757    if !is_memory {
758        let journal = crate::bridge::config::sql_journal_mode();
759        conn.pragma_update(None, "journal_mode", &journal)
760            .map_err(|e| BlockError::Runtime(format!("journal_mode={journal}: {e}")))?;
761    }
762    let busy_ms = crate::bridge::config::sql_busy_timeout().as_millis() as i64;
763    conn.pragma_update(None, "busy_timeout", busy_ms)
764        .map_err(|e| BlockError::Runtime(format!("busy_timeout pragma: {e}")))?;
765    info!(label, path = %path.display(), busy_ms, "sqlite initialized");
766    let interrupt = Arc::new(conn.get_interrupt_handle());
767    let conn = Arc::new(Mutex::new(conn));
768    Ok((conn, interrupt))
769}
770
771/// Build the init closure shared between the main Isle and the handler
772/// Isle.  Sets `_SCRIPT_NAME`, registers `mlua-batteries` `std.*`, and
773/// configures `package.path` / `package.searchers` so `require "agent"`
774/// (and any `blocks/` module) works inside the Lua VM.
775///
776/// Returns an `FnOnce` so each call produces a fresh closure; this lets
777/// both Isles be spawned from the same config without `Clone` bounds on
778/// the captured `HashMap`.
779fn build_isle_init(
780    script_name: String,
781    script_dir: String,
782    blocks_paths: String,
783    prompt: Option<String>,
784    context: Option<String>,
785    extra_globals: HashMap<String, serde_json::Value>,
786) -> impl FnOnce(&mlua::Lua) -> mlua::Result<()> + Send + 'static {
787    move |lua| {
788        // Set script name before registering bridges (used by log.* for attribution)
789        lua.globals().set("_SCRIPT_NAME", script_name.as_str())?;
790        if let Some(ref p) = prompt {
791            lua.globals().set("_PROMPT", p.as_str())?;
792        }
793        if let Some(ref c) = context {
794            lua.globals().set("_CONTEXT", c.as_str())?;
795        }
796
797        mlua_batteries::register_all(lua, "std")?;
798
799        // ── extra_globals from BlockConfig ──────────────────────────
800        // Inject SDK-supplied parameterisation values into the Lua
801        // global namespace. Registered after mlua_batteries so that
802        // any value that *intentionally* shadows a `std.*` symbol
803        // wins — callers are responsible for not stomping on bridges
804        // they need.
805        for (name, value) in &extra_globals {
806            let lua_value = crate::bridge::json_to_lua(lua, value.clone())
807                .map_err(|e| mlua::Error::external(format!("extra_globals[{name}]: {e}")))?;
808            lua.globals().set(name.as_str(), lua_value)?;
809        }
810
811        // ── package.path ──────────────────────────────────────────────
812        // Priority: script_dir > project_root/blocks/ > exe_dir/blocks/ > default
813        let package: mlua::Table = lua.globals().get("package")?;
814        let current_path: String = package.get("path")?;
815        let new_path =
816            format!("{script_dir}/?.lua;{script_dir}/?/init.lua;{blocks_paths}{current_path}");
817        package.set("path", new_path)?;
818
819        // ── package.searchers — embedded fallback ─────────────────────
820        // Register a custom searcher that loads blocks/ modules from the
821        // sources baked in at compile time.  This is the lowest-priority
822        // searcher so filesystem copies always win.
823        let embedded: HashMap<&'static str, &'static str> =
824            EMBEDDED_BLOCKS.iter().copied().collect();
825
826        let searchers: mlua::Table = package.get("searchers")?;
827        let loader =
828            lua.create_function(move |lua, name: String| match embedded.get(name.as_str()) {
829                Some(source) => {
830                    let chunk = lua
831                        .load(*source)
832                        .set_name(format!("@embedded:blocks/{name}/init.lua"));
833                    let func = chunk.into_function()?;
834                    Ok(mlua::Value::Function(func))
835                }
836                None => {
837                    let msg = lua.create_string(format!("\n\tno embedded block '{name}'"))?;
838                    Ok(mlua::Value::String(msg))
839                }
840            })?;
841        // Append as the last searcher so filesystem paths remain preferred.
842        let next_idx = searchers.raw_len() + 1;
843        searchers.raw_set(next_idx, loader)?;
844
845        Ok(())
846    }
847}
848
849/// Spawn the dedicated handler Isle.
850///
851/// The handler Isle runs Lua bus handlers (`bus.on` / `bus.on_any`) on a
852/// separate OS thread with its own `tokio` current-thread runtime, keeping
853/// CPU-bound handlers from starving the main Isle's grace timers.
854///
855/// Bridge registration is deferred to a follow-up `exec` in `run()` because
856/// `HostContext` is not constructible until both Isles exist (the struct
857/// itself holds `Arc<AsyncIsle>` for both).
858async fn spawn_handler_isle(
859    script_name: String,
860    script_dir: String,
861    blocks_paths: String,
862    prompt: Option<String>,
863    context: Option<String>,
864    extra_globals: HashMap<String, serde_json::Value>,
865) -> BlockResult<(Arc<AsyncIsle>, AsyncIsleDriver)> {
866    let init = build_isle_init(
867        script_name,
868        script_dir,
869        blocks_paths,
870        prompt,
871        context,
872        extra_globals,
873    );
874    let (isle, driver) = AsyncIsle::builder()
875        .thread_name("agent-block-handler-isle")
876        .spawn(init)
877        .await
878        .map_err(|e| BlockError::Runtime(format!("handler isle spawn failed: {e}")))?;
879    info!(
880        thread_name = "agent-block-handler-isle",
881        "handler Isle spawned"
882    );
883    Ok((Arc::new(isle), driver))
884}
885
886#[cfg(feature = "mesh")]
887fn hex_decode_32(s: &str) -> Result<[u8; 32], String> {
888    let s = s.trim();
889    if s.len() != 64 {
890        return Err(format!("expected 64 hex chars, got {}", s.len()));
891    }
892    let mut out = [0u8; 32];
893    for (i, byte) in out.iter_mut().enumerate() {
894        let hi = u8::from_str_radix(&s[2 * i..2 * i + 1], 16)
895            .map_err(|e| format!("invalid hex at position {}: {e}", 2 * i))?;
896        let lo = u8::from_str_radix(&s[2 * i + 1..2 * i + 2], 16)
897            .map_err(|e| format!("invalid hex at position {}: {e}", 2 * i + 1))?;
898        *byte = (hi << 4) | lo;
899    }
900    Ok(out)
901}
902
903/// Concrete payloads resolved from the `*Source` enums on [`BlockConfig`]
904/// before any Isle setup begins.
905struct ResolvedSources {
906    script_source: String,
907    script_name: String,
908    script_dir: PathBuf,
909    prompt: Option<String>,
910    context: Option<String>,
911    secret_key: Option<String>,
912}
913
914/// Resolve the script / prompt / context / secret-key sources to their
915/// concrete values, reading from disk or environment exactly once.
916fn resolve_sources(config: &BlockConfig) -> BlockResult<ResolvedSources> {
917    let (script_source, script_name, script_dir) = match &config.script {
918        ScriptSource::Path(p) => {
919            let source = std::fs::read_to_string(p)
920                .map_err(|e| BlockError::Script(format!("{}: {e}", p.display())))?;
921            let name = p
922                .file_name()
923                .map(|n| n.to_string_lossy().to_string())
924                .unwrap_or_else(|| "unknown".to_string());
925            let dir = p
926                .parent()
927                .map(|d| d.to_path_buf())
928                .unwrap_or_else(|| PathBuf::from("."));
929            (source, name, dir)
930        }
931        ScriptSource::Inline { source, name } => {
932            (source.clone(), name.clone(), config.project_root.clone())
933        }
934        ScriptSource::DefaultAgent => (
935            DEFAULT_AGENT_INVOKER.to_string(),
936            "default_agent_invoker.lua".to_string(),
937            config.project_root.clone(),
938        ),
939    };
940
941    let prompt: Option<String> = match &config.prompt {
942        Some(PromptSource::Inline(s)) => Some(s.clone()),
943        Some(PromptSource::File(p)) => Some(
944            std::fs::read_to_string(p)
945                .map_err(|e| BlockError::Script(format!("prompt file {}: {e}", p.display())))?,
946        ),
947        None => None,
948    };
949    let context: Option<String> = match &config.context {
950        Some(PromptSource::Inline(s)) => Some(s.clone()),
951        Some(PromptSource::File(p)) => Some(
952            std::fs::read_to_string(p)
953                .map_err(|e| BlockError::Script(format!("context file {}: {e}", p.display())))?,
954        ),
955        None => None,
956    };
957    let secret_key: Option<String> = match &config.secret_key {
958        Some(SecretKeySource::Inline(s)) => Some(s.clone()),
959        Some(SecretKeySource::Env(var)) => std::env::var(var).ok(),
960        None => None,
961    };
962
963    Ok(ResolvedSources {
964        script_source,
965        script_name,
966        script_dir,
967        prompt,
968        context,
969        secret_key,
970    })
971}
972
973/// Load `.env` from the project root into the process environment so Lua's
974/// `std.env.get()` observes it. A missing file is intentionally ignored.
975fn load_dotenv(project_root: &Path) {
976    let env_path = project_root.join(".env");
977    match dotenvy::from_path(&env_path) {
978        Ok(()) => info!(path = %env_path.display(), ".env loaded"),
979        Err(dotenvy::Error::Io(_)) => {} // file not found — fine
980        Err(e) => tracing::warn!(path = %env_path.display(), error = %e, ".env parse error"),
981    }
982}
983
984/// Background auto-serve dispatcher task handle plus its cancellation token,
985/// or `None` when auto-serve is disabled.
986type AutoServeState = Option<(tokio::task::JoinHandle<()>, CancellationToken)>;
987
988/// EventBus wiring produced by [`setup_event_bus`].
989struct BusSetup {
990    event_bus: Arc<Mutex<Option<EventBus>>>,
991    bus_tx: mpsc::Sender<Event>,
992    auto_serve_state: AutoServeState,
993}
994
995/// Construct the bounded EventBus channel, pre-install host-side Rust
996/// handlers, and (when `auto_serve_bus` is set with at least one handler)
997/// spawn the background dispatcher loop before the script runs.
998fn setup_event_bus(config: &BlockConfig) -> BlockResult<BusSetup> {
999    // Construct the bounded mpsc BEFORE MeshAgent::connect so the relay
1000    // handler can hold a `bus_tx` clone and forward incoming requests
1001    // into the dispatcher. Capacity is ENV-driven (see bridge::config).
1002    let bus_capacity = crate::bridge::config::bus_capacity();
1003    let (bus_tx, bus_rx) = mpsc::channel::<Event>(bus_capacity);
1004    let event_bus = Arc::new(Mutex::new(Some(EventBus::new(bus_rx))));
1005
1006    // Install host-side Rust handlers: kind-specific entries from
1007    // `host_handlers` and, when set, the kind-agnostic `host_handler`
1008    // (registered via `on_any` as the fallback for unmatched kinds).
1009    // Registered before any Lua bridge registers handlers and before
1010    // `bus.serve` takes ownership, so the EventBus already carries the
1011    // host handlers when the script starts.
1012    let has_kind_handlers = !config.host_handlers.is_empty();
1013    let has_any_handler = config.host_handler.is_some();
1014    if has_kind_handlers || has_any_handler {
1015        let mut guard = event_bus
1016            .lock()
1017            .map_err(|_| BlockError::Bus("event_bus mutex poisoned".into()))?;
1018        let bus = guard
1019            .as_mut()
1020            .ok_or_else(|| BlockError::Bus("event_bus already taken".into()))?;
1021        for (kind, handler) in &config.host_handlers {
1022            bus.on(kind.clone(), Arc::clone(handler))
1023                .map_err(|e| BlockError::Bus(format!("host_handlers on({kind}): {e}")))?;
1024        }
1025        if let Some(any_handler) = &config.host_handler {
1026            bus.on_any(Arc::clone(any_handler))
1027                .map_err(|e| BlockError::Bus(format!("host_handler on_any: {e}")))?;
1028        }
1029        info!(
1030            kind_handlers = config.host_handlers.len(),
1031            any_handler = has_any_handler,
1032            "host handlers pre-installed"
1033        );
1034    }
1035
1036    // auto-serve: when enabled with at least one host-side handler, take the
1037    // EventBus out of the Mutex *before* the script runs and spawn the
1038    // dispatcher loop on the runtime. This lets `bus.emit(kind, payload)`
1039    // from the script reach the host handler without requiring the script to
1040    // call `bus.serve()` (which blocks on signals and never returns under
1041    // programmatic embedding).
1042    let auto_serve = config.auto_serve_bus && (has_kind_handlers || has_any_handler);
1043    let auto_serve_state: AutoServeState = if auto_serve {
1044        let bus = {
1045            let mut guard = event_bus
1046                .lock()
1047                .map_err(|_| BlockError::Bus("event_bus mutex poisoned".into()))?;
1048            guard
1049                .take()
1050                .ok_or_else(|| BlockError::Bus("event_bus already taken".into()))?
1051        };
1052        let token = CancellationToken::new();
1053        let token_for_task = token.clone();
1054        let handle = tokio::spawn(async move {
1055            let mut bus = bus;
1056            if let Err(e) = bus.run(token_for_task).await {
1057                tracing::error!(error = %e, "auto-serve: dispatcher loop returned error");
1058            }
1059        });
1060        info!("auto-serve: dispatcher spawned");
1061        Some((handle, token))
1062    } else {
1063        None
1064    };
1065
1066    Ok(BusSetup {
1067        event_bus,
1068        bus_tx,
1069        auto_serve_state,
1070    })
1071}
1072
1073/// Connect to the mesh relay when `relay_url` is set, deriving the Ed25519
1074/// identity from `secret_key` (or a fresh random keypair) and wiring the
1075/// EventBus relay handler. Returns `None` when mesh is disabled.
1076#[cfg(feature = "mesh")]
1077async fn connect_mesh(
1078    relay_url: Option<&String>,
1079    secret_key: Option<&String>,
1080    bus_tx: &mpsc::Sender<Event>,
1081) -> BlockResult<Option<Arc<agent_mesh_sdk::MeshAgent>>> {
1082    let Some(relay_url) = relay_url else {
1083        return Ok(None);
1084    };
1085    let keypair = match secret_key {
1086        Some(hex_str) => {
1087            let bytes = hex_decode_32(hex_str)
1088                .map_err(|e| BlockError::Runtime(format!("secret-key: {e}")))?;
1089            agent_mesh_core::identity::AgentKeypair::from_bytes(&bytes)
1090        }
1091        None => agent_mesh_core::identity::AgentKeypair::generate(),
1092    };
1093    info!(agent_id = %keypair.agent_id(), "mesh identity");
1094    let acl = agent_mesh_core::acl::AclPolicy {
1095        default_deny: false,
1096        rules: vec![],
1097    };
1098    let handler: Arc<dyn agent_mesh_sdk::RequestHandler> =
1099        Arc::new(BusRelayHandler::new(bus_tx.clone()));
1100    let url = relay_url.clone();
1101    let agent = agent_mesh_sdk::MeshAgent::connect(keypair, &url, acl, handler)
1102        .await
1103        .map_err(|e| BlockError::Mesh(format!("connect to {relay_url} failed: {e}")))?;
1104    info!(relay_url = %relay_url, "mesh connected");
1105    Ok(Some(Arc::new(agent)))
1106}
1107
1108/// The three SQLite connections (with interrupt handles) backing the
1109/// `sql.*`, `kv.*`, and `ts.*` Lua bridges.
1110#[cfg(feature = "sqlite")]
1111struct SqliteConns {
1112    sql_conn: Arc<Mutex<rusqlite::Connection>>,
1113    sql_interrupt: Arc<rusqlite::InterruptHandle>,
1114    kv_conn: Arc<Mutex<rusqlite::Connection>>,
1115    kv_interrupt: Arc<rusqlite::InterruptHandle>,
1116    ts_conn: Arc<Mutex<rusqlite::Connection>>,
1117    ts_interrupt: Arc<rusqlite::InterruptHandle>,
1118}
1119
1120/// Open the sql / kv / ts SQLite databases, honoring the [`BlockConfig`]
1121/// path overrides and otherwise falling back to the env-driven resolution.
1122#[cfg(feature = "sqlite")]
1123fn init_sqlite(config: &BlockConfig) -> BlockResult<SqliteConns> {
1124    let sql_path = match &config.sql_path {
1125        Some(p) => p.clone(),
1126        None => crate::bridge::config::sql_path().map_err(BlockError::Runtime)?,
1127    };
1128    let (sql_conn, sql_interrupt) = open_sqlite(&sql_path, "sql")?;
1129
1130    let kv_path = match &config.kv_path {
1131        Some(p) => p.clone(),
1132        None => crate::bridge::config::kv_path().map_err(BlockError::Runtime)?,
1133    };
1134    let (kv_conn, kv_interrupt) = open_sqlite(&kv_path, "kv")?;
1135
1136    let ts_path = match &config.ts_path {
1137        Some(p) => p.clone(),
1138        None => crate::bridge::config::ts_path().map_err(BlockError::Runtime)?,
1139    };
1140    let (ts_conn, ts_interrupt) = open_sqlite(&ts_path, "ts")?;
1141
1142    Ok(SqliteConns {
1143        sql_conn,
1144        sql_interrupt,
1145        kv_conn,
1146        kv_interrupt,
1147        ts_conn,
1148        ts_interrupt,
1149    })
1150}
1151
1152/// The main and handler Isles plus their drivers, produced by [`spawn_isles`].
1153struct SpawnedIsles {
1154    isle: Arc<AsyncIsle>,
1155    driver: AsyncIsleDriver,
1156    handler_isle: Arc<AsyncIsle>,
1157    handler_driver: AsyncIsleDriver,
1158}
1159
1160/// Spawn the main Lua Isle and the dedicated handler Isle from the same
1161/// resolved script parameters. Their bridges are registered in a later pass
1162/// (via [`register_bridges`]) once the `HostContext` exists.
1163async fn spawn_isles(
1164    script_name: &str,
1165    script_dir: &str,
1166    blocks_paths: &str,
1167    prompt: Option<String>,
1168    context: Option<String>,
1169    extra_globals: &HashMap<String, serde_json::Value>,
1170) -> BlockResult<SpawnedIsles> {
1171    let (isle, driver) = AsyncIsle::spawn(build_isle_init(
1172        script_name.to_string(),
1173        script_dir.to_string(),
1174        blocks_paths.to_string(),
1175        prompt.clone(),
1176        context.clone(),
1177        extra_globals.clone(),
1178    ))
1179    .await
1180    .map_err(|e| BlockError::Runtime(format!("AsyncIsle spawn failed: {e}")))?;
1181    let isle = Arc::new(isle);
1182
1183    // handler Isle (sequential, dependencies are trivial)
1184    let (handler_isle, handler_driver) = spawn_handler_isle(
1185        script_name.to_string(),
1186        script_dir.to_string(),
1187        blocks_paths.to_string(),
1188        prompt,
1189        context,
1190        extra_globals.clone(),
1191    )
1192    .await?;
1193
1194    Ok(SpawnedIsles {
1195        isle,
1196        driver,
1197        handler_isle,
1198        handler_driver,
1199    })
1200}
1201
1202/// Register the Lua stdlib bridges on both the main Isle
1203/// (`bridge::register_all`) and the handler Isle
1204/// (`bridge::register_all_handler_side`).
1205async fn register_bridges(
1206    ctx: &HostContext,
1207    isle: &Arc<AsyncIsle>,
1208    handler_isle: &Arc<AsyncIsle>,
1209) -> BlockResult<()> {
1210    {
1211        let ctx = ctx.clone();
1212        isle.exec(move |lua| {
1213            bridge::register_all(lua, &ctx)
1214                .map_err(|e| mlua_isle::IsleError::Lua(format!("bridge register failed: {e}")))?;
1215            Ok(String::new())
1216        })
1217        .await
1218        .map_err(|e| BlockError::Runtime(format!("bridge register: {e}")))?;
1219    }
1220
1221    {
1222        let ctx = ctx.clone();
1223        handler_isle
1224            .exec(move |lua| {
1225                bridge::register_all_handler_side(lua, &ctx).map_err(|e| {
1226                    mlua_isle::IsleError::Lua(format!("handler bridge register failed: {e}"))
1227                })?;
1228                Ok(String::new())
1229            })
1230            .await
1231            .map_err(|e| BlockError::Runtime(format!("handler bridge register: {e}")))?;
1232    }
1233
1234    Ok(())
1235}
1236
1237/// Inject the [`BlockConfig::host_tools`] Rust tools into the Lua
1238/// `_TOOL_REGISTRY` so they are indistinguishable from Lua-defined tools.
1239/// Each entry becomes an Anthropic-shaped tool spec table
1240/// (`{ name, schema = { description, input_schema }, handler, group? }`)
1241/// whose `handler` bridges back into the supplied `ToolHandler::call`.
1242/// No-op when no host tools are supplied.
1243async fn inject_host_tools(isle: &Arc<AsyncIsle>, host_tools: &[HostToolSpec]) -> BlockResult<()> {
1244    if host_tools.is_empty() {
1245        return Ok(());
1246    }
1247    let host_tools = host_tools.to_vec();
1248    let tool_count = host_tools.len();
1249    isle.exec(move |lua| {
1250        let registry: mlua::Table = lua
1251            .globals()
1252            .get("_TOOL_REGISTRY")
1253            .map_err(|e| mlua_isle::IsleError::Lua(format!("get _TOOL_REGISTRY: {e}")))?;
1254        for tool in host_tools {
1255            let entry = lua
1256                .create_table()
1257                .map_err(|e| mlua_isle::IsleError::Lua(format!("create entry: {e}")))?;
1258            entry
1259                .set("name", tool.name.as_str())
1260                .map_err(|e| mlua_isle::IsleError::Lua(format!("set name: {e}")))?;
1261            // schema = { description, input_schema } — Anthropic shape
1262            let schema = lua
1263                .create_table()
1264                .map_err(|e| mlua_isle::IsleError::Lua(format!("create schema: {e}")))?;
1265            schema
1266                .set("description", tool.description.as_str())
1267                .map_err(|e| mlua_isle::IsleError::Lua(format!("set description: {e}")))?;
1268            let input_schema_lua = crate::bridge::json_to_lua(lua, tool.input_schema.clone())
1269                .map_err(|e| mlua_isle::IsleError::Lua(format!("input_schema: {e}")))?;
1270            schema
1271                .set("input_schema", input_schema_lua)
1272                .map_err(|e| mlua_isle::IsleError::Lua(format!("set input_schema: {e}")))?;
1273            entry
1274                .set("schema", schema)
1275                .map_err(|e| mlua_isle::IsleError::Lua(format!("set schema: {e}")))?;
1276            if let Some(group) = &tool.group {
1277                entry
1278                    .set("group", group.as_str())
1279                    .map_err(|e| mlua_isle::IsleError::Lua(format!("set group: {e}")))?;
1280            }
1281            let handler_arc = Arc::clone(&tool.handler);
1282            let handler_fn = lua
1283                .create_async_function(move |lua, input: mlua::Value| {
1284                    let handler = Arc::clone(&handler_arc);
1285                    async move {
1286                        let input_json = crate::bridge::lua_to_json(&lua, input)?;
1287                        let result = handler
1288                            .call(input_json)
1289                            .await
1290                            .map_err(mlua::Error::external)?;
1291                        crate::bridge::json_to_lua(&lua, result)
1292                    }
1293                })
1294                .map_err(|e| mlua_isle::IsleError::Lua(format!("create handler: {e}")))?;
1295            entry
1296                .set("handler", handler_fn)
1297                .map_err(|e| mlua_isle::IsleError::Lua(format!("set handler: {e}")))?;
1298            registry
1299                .set(tool.name.as_str(), entry)
1300                .map_err(|e| mlua_isle::IsleError::Lua(format!("registry set: {e}")))?;
1301        }
1302        Ok(String::new())
1303    })
1304    .await
1305    .map_err(|e| BlockError::Runtime(format!("host_tools inject: {e}")))?;
1306    info!(count = tool_count, "host tools injected into Lua registry");
1307    Ok(())
1308}
1309
1310/// Execute the resolved Lua script on the main Isle, racing it against the
1311/// optional caller `shutdown_token`. On cancellation the Isle is unwound via
1312/// its own cancel token before returning [`BlockError::Cancelled`].
1313async fn execute_script(
1314    isle: &Arc<AsyncIsle>,
1315    script_source: &str,
1316    script_name: &str,
1317    shutdown_token: Option<&CancellationToken>,
1318) -> BlockResult<()> {
1319    let _exec_span = info_span!("execute", script = %script_name);
1320
1321    let mut task = isle.spawn_coroutine_eval(script_source);
1322    let task_cancel = task.cancel_token().clone();
1323    match shutdown_token {
1324        Some(token) => {
1325            tokio::select! {
1326                biased;
1327                _ = token.cancelled() => {
1328                    task_cancel.cancel();
1329                    // Wait for the Isle to unwind so the VM is in a
1330                    // consistent state before driver shutdown. The
1331                    // debug hook fires at the next HOOK_INTERVAL.
1332                    let _ = (&mut task).await;
1333                    info!("shutdown_token: cancelled by caller");
1334                    Err(BlockError::Cancelled)
1335                }
1336                res = &mut task => res.map(|_| ()).map_err(|e| BlockError::Script(format!("{e}"))),
1337            }
1338        }
1339        None => (&mut task)
1340            .await
1341            .map(|_| ())
1342            .map_err(|e| BlockError::Script(format!("{e}"))),
1343    }
1344}
1345
1346/// Drain the auto-serve dispatcher: give it a grace window to flush queued
1347/// events, then cancel and bound-join it. No-op when auto-serve is off.
1348async fn drain_auto_serve(auto_serve_state: AutoServeState) {
1349    if let Some((handle, token)) = auto_serve_state {
1350        let grace_ms = crate::bridge::config::task_grace_ms();
1351        let grace = Duration::from_millis(grace_ms);
1352        tokio::time::sleep(grace).await;
1353        token.cancel();
1354        match tokio::time::timeout(grace, handle).await {
1355            Ok(Ok(())) => info!("auto-serve: dispatcher shut down cleanly"),
1356            Ok(Err(join_err)) => {
1357                tracing::error!(error = %join_err, "auto-serve: dispatcher task join error");
1358            }
1359            Err(_) => {
1360                tracing::warn!(
1361                    grace_ms,
1362                    "auto-serve: dispatcher join timed out after cancel; forcing exit"
1363                );
1364            }
1365        }
1366    }
1367}
1368
1369/// Tear down host resources in order: disconnect MCP servers, shut down the
1370/// main Isle driver (fatal on error), then the handler Isle driver (logged,
1371/// non-fatal so a handler-thread panic does not poison the process exit).
1372async fn shutdown(
1373    mcp_manager: &Arc<RwLock<McpManager>>,
1374    driver: AsyncIsleDriver,
1375    handler_driver: AsyncIsleDriver,
1376) -> BlockResult<()> {
1377    let _shutdown_span = info_span!("shutdown");
1378
1379    mcp_manager.write().await.disconnect_all().await?;
1380
1381    driver
1382        .shutdown()
1383        .await
1384        .map_err(|e| BlockError::Runtime(format!("AsyncIsle shutdown failed: {e}")))?;
1385
1386    // Handler Isle shutdown is independent of main shutdown: a failure
1387    // here (e.g. ThreadPanic on the handler thread) is logged but does
1388    // not poison the main process exit. The main Isle has already
1389    // been stopped cleanly above.
1390    match handler_driver.shutdown().await {
1391        Ok(()) => info!(
1392            thread_name = "agent-block-handler-isle",
1393            "handler Isle shut down"
1394        ),
1395        Err(e) => tracing::error!(
1396            error = %e,
1397            thread_name = "agent-block-handler-isle",
1398            "handler Isle shutdown failed"
1399        ),
1400    }
1401
1402    Ok(())
1403}
1404
1405/// Primary SDK entry point: run one agent-block execution to completion.
1406///
1407/// Given a fully-populated [`BlockConfig`], this drives the entire host
1408/// orchestration for a single run: it resolves the script / prompt / context /
1409/// secret-key sources, loads `.env` from the project root, spawns the main and
1410/// handler Lua Isles, opens the kv / sql / ts SQLite connections, optionally
1411/// connects to the mesh relay, initialises the MCP manager, injects the Lua
1412/// stdlib bridge plus any host-supplied tools / handlers, executes the script,
1413/// and finally tears everything down (MCP disconnect, Isle shutdown, auto-serve
1414/// dispatcher join).
1415///
1416/// The returned future is `Send`, so SDK consumers may `tokio::spawn` it.
1417///
1418/// # Errors
1419///
1420/// Returns [`BlockError`] when any stage fails: source resolution / file reads
1421/// ([`BlockError::Script`]), mesh connect ([`BlockError::Mesh`]), EventBus or
1422/// Isle setup ([`BlockError::Bus`] / [`BlockError::Runtime`]), or a script
1423/// runtime error ([`BlockError::Script`]). When a `shutdown_token` is supplied
1424/// and fires before the script finishes, returns [`BlockError::Cancelled`]
1425/// after the shutdown sequence completes.
1426pub async fn run(config: BlockConfig) -> BlockResult<()> {
1427    // ── Resolve sources ───────────────────────────────────────────
1428    // Convert the `Source` enums on `BlockConfig` to their concrete
1429    // payloads before any Isle setup. `File`/`Path`/`Env` variants
1430    // read from disk / environment exactly once, here at the start.
1431    let ResolvedSources {
1432        script_source,
1433        script_name,
1434        script_dir: script_dir_pathbuf,
1435        prompt: prompt_resolved,
1436        context: context_resolved,
1437        secret_key: secret_key_resolved,
1438    } = resolve_sources(&config)?;
1439
1440    // NOTE: We previously held entered span guards across awaits for nested
1441    // span context. That made the `run()` future `!Send`, which prevents
1442    // SDK consumers from `tokio::spawn(run(config))`. Span context is
1443    // attached to events via fields on the `info_span!` calls below; the
1444    // missing nesting is an acceptable trade-off for `Send` correctness.
1445    let _root_span = info_span!("agent_block", script = %script_name);
1446
1447    // ── .env ──────────────────────────────────────────────────────
1448    // Load .env from project_root if present. Variables are merged into
1449    // the process environment so Lua's `std.env.get()` picks them up.
1450    load_dotenv(&config.project_root);
1451
1452    // ── Init ──────────────────────────────────────────────────────
1453    let _init_span = info_span!("init");
1454
1455    // ── EventBus + host handlers + auto-serve dispatcher ──────────────
1456    // Construct the bus channel, pre-install host-side Rust handlers, and
1457    // (when configured) spawn the background dispatcher before the script.
1458    let BusSetup {
1459        event_bus,
1460        bus_tx,
1461        auto_serve_state,
1462    } = setup_event_bus(&config)?;
1463
1464    #[cfg(feature = "mesh")]
1465    let mesh_agent = connect_mesh(
1466        config.relay_url.as_ref(),
1467        secret_key_resolved.as_ref(),
1468        &bus_tx,
1469    )
1470    .await?;
1471    // `secret_key` / `relay_url` are consumed only by the mesh connect path.
1472    #[cfg(not(feature = "mesh"))]
1473    let _ = (&secret_key_resolved, &config.relay_url);
1474
1475    let mcp_manager = Arc::new(RwLock::new(McpManager::with_rpc_timeout(
1476        config.mcp_rpc_timeout,
1477    )?));
1478
1479    // Resolve project_root to absolute path.
1480    // canonicalize() can fail if the path doesn't exist; fall back to
1481    // joining with current_dir to guarantee an absolute path.
1482    let project_root = config
1483        .project_root
1484        .canonicalize()
1485        .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(&config.project_root)))?;
1486
1487    // HTTP client: prefer the SDK-supplied client if any; otherwise
1488    // construct a fresh default reqwest::Client (legacy behavior).
1489    let http_client = config.http_client.clone().unwrap_or_default();
1490
1491    // ── SQLite init (sql + kv + ts get separate DB files) ─────────────
1492    // BlockConfig overrides take precedence; otherwise the env-driven
1493    // resolution in `bridge::config::*` applies (see crate docs). Gated
1494    // behind the `sqlite` feature; when off, the sql/kv/ts bridges are not
1495    // registered and the `sql_path` / `kv_path` / `ts_path` config fields
1496    // (which remain present for API stability) are ignored.
1497    #[cfg(feature = "sqlite")]
1498    let SqliteConns {
1499        sql_conn,
1500        sql_interrupt,
1501        kv_conn,
1502        kv_interrupt,
1503        ts_conn,
1504        ts_interrupt,
1505    } = init_sqlite(&config)?;
1506
1507    // Use the script dir derived from the resolved `ScriptSource` for
1508    // `package.path` lookups. For inline / default-agent variants the dir
1509    // falls back to `project_root` (set during source resolution above).
1510    let script_dir = script_dir_pathbuf.to_string_lossy().to_string();
1511
1512    // Precompute values captured by the init closure so we don't need to
1513    // move the full `HostContext` into it (HostContext now holds
1514    // `Arc<AsyncIsle>`, which is available only after `AsyncIsle::spawn`
1515    // returns — classic chicken-and-egg). All bridge registrations run in a
1516    // second pass via `isle.exec` below.
1517    let blocks_paths = build_blocks_path(&project_root);
1518    let prompt = prompt_resolved.clone();
1519    let context = context_resolved.clone();
1520
1521    // ── main + handler Isles ──────────────────────────────────────
1522    let SpawnedIsles {
1523        isle,
1524        driver,
1525        handler_isle,
1526        handler_driver,
1527    } = spawn_isles(
1528        &script_name,
1529        &script_dir,
1530        &blocks_paths,
1531        prompt,
1532        context,
1533        &config.extra_globals,
1534    )
1535    .await?;
1536
1537    // Wire both Isles into McpManager so Lua notification callbacks can be
1538    // dispatched from the rmcp task thread.
1539    // - handler_isle: sampling/createMessage dispatch (exec on handler Isle)
1540    // - main_isle: progress/log notification dispatch (exec on main Isle so
1541    //   user callback upvalues are preserved — no bytecode dump/reload needed)
1542    {
1543        let mut mgr = mcp_manager.write().await;
1544        mgr.set_handler_isle(Arc::clone(&handler_isle));
1545        mgr.set_main_isle(Arc::clone(&isle));
1546    }
1547
1548    // ── HostContext + bridge registration ──────────────────────────────
1549    // Wrap the isle in an Arc so `HostContext` can hand it to
1550    // `bridge::bus` (which uses `AsyncIsle::coroutine_call` to invoke Lua
1551    // handlers from the EventBus dispatcher task).
1552    let ctx = HostContext {
1553        project_root,
1554        #[cfg(feature = "mesh")]
1555        mesh_agent,
1556        mcp_manager: Arc::clone(&mcp_manager),
1557        http_client,
1558        #[cfg(feature = "sqlite")]
1559        sql_conn,
1560        #[cfg(feature = "sqlite")]
1561        sql_interrupt,
1562        #[cfg(feature = "sqlite")]
1563        kv_conn,
1564        #[cfg(feature = "sqlite")]
1565        kv_interrupt,
1566        #[cfg(feature = "sqlite")]
1567        ts_conn,
1568        #[cfg(feature = "sqlite")]
1569        ts_interrupt,
1570        isle: Arc::clone(&isle),
1571        handler_isle: Arc::clone(&handler_isle),
1572        bus_tx: bus_tx.clone(),
1573        event_bus: Arc::clone(&event_bus),
1574    };
1575
1576    register_bridges(&ctx, &isle, &handler_isle).await?;
1577
1578    // ── Inject host_tools into the Lua tool registry ───────────────
1579    // Done after `bridge::register_all` so `_TOOL_REGISTRY` exists.
1580    inject_host_tools(&isle, &config.host_tools).await?;
1581
1582    drop(_init_span);
1583
1584    // ── Execute ───────────────────────────────────────────────────
1585    // When `shutdown_token` is supplied, race the script future against
1586    // the caller's cancellation signal. On cancel, propagate to the Isle
1587    // via the AsyncTask's cancel token so the debug hook unwinds the Lua
1588    // VM, then continue into the shutdown sequence below (we still want
1589    // to release MCP/mesh handles and join the auto-serve dispatcher
1590    // before returning).
1591    let script_result = execute_script(
1592        &isle,
1593        &script_source,
1594        &script_name,
1595        config.shutdown_token.as_ref(),
1596    )
1597    .await;
1598
1599    // ── auto-serve drain + cancel ─────────────────────────────────
1600    // Let the dispatcher drain events queued by the script, then signal
1601    // shutdown and bound the join. Mirrors `bus.serve`'s grace pattern.
1602    drain_auto_serve(auto_serve_state).await;
1603
1604    // ── Shutdown ──────────────────────────────────────────────────
1605    shutdown(&mcp_manager, driver, handler_driver).await?;
1606
1607    script_result
1608}
1609
1610/// mesh → bus source adapter.
1611///
1612/// Implements [`agent_mesh_sdk::RequestHandler`] by packaging every incoming
1613/// mesh request into an [`Event`] with `kind = "mesh"`, pushing it onto the
1614/// bounded `bus_tx` channel, and awaiting the Lua handler's ack over a
1615/// oneshot channel carried inside the event.
1616///
1617/// Error paths (all `tracing::error!`-logged — silent-err-drop policy):
1618///
1619/// | Failure                   | Return value                           |
1620/// |---------------------------|----------------------------------------|
1621/// | `bus_tx.send` closed/full | `{"error": "bus channel closed"}`      |
1622/// | ack receiver dropped      | `{"error": "ack dropped"}`             |
1623/// | Lua handler `BlockError`  | `{"error": "<handler error>"}`         |
1624/// | Handler exceeded 30s      | `{"error": "handler timeout"}`         |
1625///
1626/// The 30s ack timeout mirrors the client-side timeout on `mesh.request`
1627/// (see `src/bridge/mesh.rs`).
1628#[cfg(feature = "mesh")]
1629struct BusRelayHandler {
1630    tx: mpsc::Sender<Event>,
1631}
1632
1633#[cfg(feature = "mesh")]
1634impl BusRelayHandler {
1635    fn new(tx: mpsc::Sender<Event>) -> Self {
1636        Self { tx }
1637    }
1638}
1639
1640/// Bound used for both the mesh-adapter ack wait and other source timeouts.
1641#[cfg(feature = "mesh")]
1642const BUS_ACK_TIMEOUT: Duration = Duration::from_secs(30);
1643
1644#[cfg(feature = "mesh")]
1645#[async_trait::async_trait]
1646impl agent_mesh_sdk::RequestHandler for BusRelayHandler {
1647    async fn handle(
1648        &self,
1649        from: &agent_mesh_core::identity::AgentId,
1650        payload: &serde_json::Value,
1651        _cancel: agent_mesh_sdk::CancelToken,
1652    ) -> serde_json::Value {
1653        let id = uuid::Uuid::new_v4().to_string();
1654        let meta = serde_json::json!({"from": from.to_string()});
1655        let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
1656        let event = Event {
1657            kind: "mesh".into(),
1658            id: id.clone(),
1659            payload: payload.clone(),
1660            meta,
1661            ack_tx: Some(ack_tx),
1662        };
1663
1664        if let Err(e) = self.tx.send(event).await {
1665            tracing::error!(error = %e, id = %id, "bus channel closed; rejecting mesh request");
1666            return serde_json::json!({"error": "bus channel closed"});
1667        }
1668
1669        match tokio::time::timeout(BUS_ACK_TIMEOUT, ack_rx).await {
1670            Ok(Ok(Ok(v))) => v,
1671            Ok(Ok(Err(e))) => {
1672                tracing::error!(id = %id, error = %e, "mesh handler returned error");
1673                serde_json::json!({"error": e.to_string()})
1674            }
1675            Ok(Err(e)) => {
1676                tracing::error!(id = %id, error = %e, "mesh ack receiver dropped");
1677                serde_json::json!({"error": "ack dropped"})
1678            }
1679            Err(_) => {
1680                tracing::error!(id = %id, timeout_secs = BUS_ACK_TIMEOUT.as_secs(), "mesh handler timeout");
1681                serde_json::json!({"error": "handler timeout"})
1682            }
1683        }
1684    }
1685}