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