summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-26 20:14:37 -0600
committerT <t@tjp.lol>2026-05-27 06:26:36 -0600
commit1f0915edbe0213e8bc134922f10933468d35a172 (patch)
tree11834769555c7037f3393ef8d98bf5841846144a
parentb788eb05c6d194b91fdc141b6655e61ccaa76ddb (diff)
finish lua runtime makeover
- new multi-tool registration via ToolSource - thread per source-or-standalone-tool - switched to zig 0.16 Io threading interface - cli: include `luv` package and run concurrent lua tools via libuv - one single long-lived lua_State for the whole cli program
l---------.panto1
-rw-r--r--LUA_MAKEOVER.md524
-rw-r--r--docs/phase-3.md7
-rw-r--r--libpanto/src/agent.zig772
-rw-r--r--libpanto/src/anthropic_messages_json.zig14
-rw-r--r--libpanto/src/openai_chat_json.zig14
-rw-r--r--libpanto/src/root.zig5
-rw-r--r--libpanto/src/tool.zig27
-rw-r--r--libpanto/src/tool_registry.zig356
-rw-r--r--libpanto/src/tool_source.zig100
-rw-r--r--pantograph-for-studio-rail-system-200-cm.jpgbin123641 -> 0 bytes
-rw-r--r--src/extension_loader.zig216
-rw-r--r--src/lua_bridge.zig53
-rw-r--r--src/lua_runtime.zig678
-rw-r--r--src/lua_tool.zig418
-rw-r--r--src/main.zig19
-rw-r--r--src/ping_tool.zig8
17 files changed, 1853 insertions, 1359 deletions
diff --git a/.panto b/.panto
new file mode 120000
index 0000000..b726568
--- /dev/null
+++ b/.panto
@@ -0,0 +1 @@
+examples \ No newline at end of file
diff --git a/LUA_MAKEOVER.md b/LUA_MAKEOVER.md
deleted file mode 100644
index 416468c..0000000
--- a/LUA_MAKEOVER.md
+++ /dev/null
@@ -1,524 +0,0 @@
-# Lua Runtime Makeover
-
-Side project, separate from the main phase plan. Reworks panto's Lua
-embedding into something that fits Lua's actual concurrency model and
-opens the door to a luarocks-based extension ecosystem.
-
-Phase 3 as shipped is fine and stays. This document describes what
-replaces it.
-
-## Why
-
-The current Lua embedding has three problems, in increasing order of
-how badly they constrain us:
-
-1. **One `lua_State` per tool call.** Every `LuaTool.invoke` builds and
- tears down a fresh interpreter. Module-global state is impossible.
- Top-level extension code runs over and over. The API only makes
- sense for stateless single-shot handlers, which is not how Lua
- wants to be used.
-2. **The `Tool` contract is "thread-safe."** Right for native
- extensions; wrong for Lua. Lua's concurrency primitive is the
- coroutine, not the OS thread. A single `lua_State` is not safe for
- concurrent host entry, so the only way to honor "thread-safe" with
- Lua is one state per call — which we have, badly.
-3. **No path to a real Lua ecosystem.** The current setup discovers
- `.lua` files on disk and that's it. There's no answer to "how do
- extension authors depend on lua-cjson" or "how does an HTTP-using
- tool work." The eventual answer to both is luarocks, and the
- current architecture has no place for it.
-
-## Shape of the fix
-
-Three independent pieces that compose:
-
-1. **`ToolSource` in libpanto.** A new kind of registration alongside
- `Tool`. A source owns one or more tools and receives all calls
- targeting them, as a batch, on one thread per turn. Different
- sources still run in parallel. Lua becomes one source.
-2. **Long-lived `lua_State` with cooperative scheduling.** The panto
- CLI maintains exactly one `lua_State` for its entire lifetime.
- Extension top-level code runs once at startup. Each tool call is a
- coroutine. A libuv event loop drives suspended coroutines.
-3. **luarocks as the package manager.** Both panto's own runtime
- batteries (luv, coro-*, future additions) and user-installed
- extensions come through luarocks, installed into a tree under
- `$XDG_DATA_HOME/panto/`. luarocks itself is embedded in the panto
- binary as Lua source.
-
-## libpanto: `ToolSource`
-
-Native tools keep the existing `Tool` API unchanged. Adapters that
-back multiple tools through a shared runtime use the new `ToolSource`:
-
-```zig
-pub const ToolSource = struct {
- name: []const u8, // diagnostic only ("panto-lua")
- tools: []const Tool.Decl, // metadata only; no per-tool vtable
- ctx: *anyopaque,
- vtable: *const VTable,
-
- pub const VTable = struct {
- /// libpanto guarantees: for a given turn, all ToolUse calls
- /// whose tool name belongs to this source are delivered in
- /// one invoke_batch call, on one thread. Different sources
- /// still execute in parallel.
- ///
- /// The source decides internal scheduling (coroutines,
- /// sequential, internal worker pool).
- invoke_batch: *const fn (
- ctx: *anyopaque,
- calls: []const Call,
- results: []CallResult, // parallel array, pre-allocated
- allocator: Allocator,
- ) anyerror!void,
-
- deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void,
- };
-
- pub const Call = struct {
- tool_name: []const u8,
- input: []const u8,
- };
-
- pub const CallResult = union(enum) {
- ok: []u8, // owned by allocator
- err: anyerror,
- };
-};
-
-pub const Tool.Decl = struct {
- name: []const u8,
- description: []const u8,
- schema_json: []const u8,
-};
-```
-
-`ToolRegistry` indexes by tool name with a tagged value:
-`{ .single: *Tool }` or `{ .source: *ToolSource, .tool_index: usize }`.
-
-`Agent.registerToolSource(src: ToolSource) !void` is the new entry
-point.
-
-### Agent loop change
-
-In `runStep`, after collecting ToolUse blocks for a turn:
-
-1. Group them by owning source. Single-`Tool` entries form
- single-entry groups.
-2. Spawn one OS thread per group.
-3. Each thread calls either `tool.vtable.invoke` (single) or
- `source.vtable.invoke_batch` (batched).
-4. Join. Assemble ToolResult blocks in the original order.
-
-**Concurrency contract becomes:** different groups run in parallel; a
-single group is the source's problem. The "thread-safe" promise still
-holds for native `Tool`s. For Lua, it relaxes to "coroutine-safe
-within the panto-lua runtime."
-
-## Lua runtime
-
-### One `lua_State`, many coroutines, one event loop
-
-The panto CLI creates a single `lua_State` at startup. Every Lua
-extension is loaded into it. All top-level extension code runs once.
-Module-global state is real and persistent across calls.
-
-When `invoke_batch` fires for the panto-lua source:
-
-```
-1. for each call: coroutine.create(handler), coroutine.resume(co, args)
-2. uv.run() until all coroutines have completed (or errored)
-3. collect results into the CallResult array
-```
-
-That's the entire scheduler. The work happens inside libuv: when a
-coroutine calls a yield-aware libuv operation (HTTP, fs, subprocess,
-sleep) it suspends; libuv resumes it when the event fires.
-
-A wrapper layer translates libuv's callback shape into coroutine
-yields. The Luvit project's `coro-*` modules (coro-fs, coro-net,
-coro-http, coro-channel, coro-spawn) do this upstream for the common
-operations. Where they don't cover something, we write small wrappers
-in panto's own Lua code. The pattern is ~10 lines:
-
-```lua
-local function fs_open(path, flags, mode)
- local co = coroutine.running()
- uv.fs_open(path, flags, mode, function(err, fd)
- coroutine.resume(co, err, fd)
- end)
- return coroutine.yield()
-end
-```
-
-### What this gets us
-
-- True cooperative parallel I/O within a batch. Three concurrent
- `web_fetch` calls go through three concurrent sockets; total
- latency is `max(req1, req2, req3)`, not sum.
-- First-class async subprocess. A `bash`-style tool that runs three
- commands at once does it without blocking the runtime.
-- Module-global state for extensions that want it (rate limiters,
- caches, lazy connection pools, etc.).
-- Extension top-level code runs once. Initialization is real.
-
-### The honest caveat
-
-Cooperative scheduling only helps when handlers yield. A handler that
-calls a non-yielding C function — raw `os.execute`, `io.read` against
-a slow file, an FFI call — blocks its siblings until it returns.
-Document loudly. The escape hatch is "use native extensions for
-work that can't yield."
-
-This is the same trade Python's asyncio makes with `requests` vs
-`aiohttp`. Panto's recommended posture: handlers should use libuv via
-the coro-* wrappers (or other luv-aware libraries) for I/O. Pure
-compute is fine. Calling `socket.http.request` or `os.execute` will
-work but blocks the batch.
-
-### Why libuv (not cqueues)
-
-Considered cqueues + lua-http. Better HTTP story (HTTP/2),
-coroutine-native API. Lost on **subprocess**, which has no
-maintained cqueues binding and which matters a lot for a coding
-agent. Also lost on familiarity — luv is what every Neovim user has
-seen.
-
-Trade accepted: HTTP/1.1 only via coro-http, plus a small wrapper
-layer panto maintains for the libuv operations the coro-* set
-doesn't cover.
-
-## luarocks as the package manager
-
-### Distribution model
-
-luarocks is embedded in the panto binary as `@embedFile`'d Lua
-source. At startup the runtime:
-
-1. Computes `$PANTO_HOME = $XDG_DATA_HOME/panto` (default
- `~/.local/share/panto`).
-2. Configures the embedded `lua_State`'s `package.path` and
- `package.cpath` to look under `$PANTO_HOME/share/lua/5.4/` and
- `$PANTO_HOME/lib/lua/5.4/`.
-3. Bootstraps the embedded luarocks against
- `--tree=$PANTO_HOME`.
-4. Reconciles a "runtime batteries" manifest (luv, coro-fs,
- coro-http, coro-net, coro-channel, coro-spawn, plus any future
- additions) — installs missing rocks, no-ops if present.
-5. Iterates user extensions from config. For `luarocks:foo`-style
- references, ensures `foo` is installed.
-6. Hands control to the agent loop.
-
-luarocks 3.12+ no-ops on already-installed rocks and caches the
-upstream manifest, so steps 4–5 are cheap on every run after the
-first.
-
-Network failures on later runs are swallowed: the rocks are already
-there. First-run-with-no-network degrades to "no Lua extensions
-work" — native panto features and the agent loop are unaffected.
-
-### Why fully luarocks-based
-
-- One mechanism for everything Lua-distribution. Runtime batteries
- and user extensions install the same way.
-- Single small binary. No vendored libuv, no vendored luv, no
- `@embedFile`'d coro-* sources. luarocks itself is ~1MB of Lua
- source that compresses well.
-- Version flexibility for batteries without re-shipping panto.
-- Matches Neovim's rocks.nvim direction — the most relevant
- ecosystem signal for "Lua as a serious distribution target."
-- User extension story is genuinely the same as the batteries
- story. No special cases.
-
-### Distributable artifact
-
-Single `panto` binary contains:
-
-- Zig CLI + libpanto
-- Lua 5.4 (already vendored)
-- luarocks Lua source, embedded via `@embedFile`
-- A small Zig-side bootstrap that configures `package.path` for the
- embedded luarocks code
-
-Everything else lives under `$PANTO_HOME` and is installed on first
-run.
-
-## Migration shape
-
-Independent chunks of work, roughly in order:
-
-1. **`ToolSource` in libpanto.** Add the type, registry tagging, and
- the per-source-thread fan-out in `runStep`. `Tool` unchanged.
- Native extensions keep working.
-2. **Long-lived `lua_State` runtime in the CLI.** New module
- (`lua_runtime.zig`). Loads all discovered Lua extensions once.
- Registers itself as one `ToolSource` named `panto-lua`.
- `invoke_batch` runs each call in a coroutine and drives
- `uv.run()`. No batteries yet — handlers run sync.
-3. **Embed luarocks.** `build.zig.zon` fetches luarocks source.
- `build.zig` embeds it via `@embedFile`. Runtime bootstraps it at
- startup against `$PANTO_HOME`.
-4. **Install luv as the first battery.** Verify the cooperative
- scheduler actually works end-to-end with a real yield-aware
- library.
-5. **Install coro-* batteries.** Wire them as the default
- I/O surface for extension authors.
-6. **User extension config syntax.** `luarocks:foo` references in
- panto's config file; lockfile equivalent for reproducibility.
- Mostly orthogonal to everything above.
-7. **Delete `LuaTool` and per-call `lua_State` machinery.** Phase 3
- code retires.
-
-Documentation updates: phase-3.md gains a "superseded by
-LUA_MAKEOVER.md for the Lua runtime; native extension contract
-unchanged" note. The contract for native extensions ("thread-safe")
-stays as-is.
-
-## Open questions
-
-These came up during design and need resolution before
-implementation. We'll edit answers in here as decisions land.
-
-### Q1: luarocks's own C dependencies
-
-luarocks attempts to `require` several optional Lua modules via
-`pcall` and falls back to shelling out or to its own pure-Lua
-implementations when they're missing. The optional set:
-
-- **LuaSocket** (`socket.http`, `socket.ftp`) — for HTTP/FTP
- downloads. Without it: shell out to a configured downloader
- (`curl` or `wget`).
-- **LuaSec** (`ssl.https`) — for HTTPS. Without it *and* without
- the LuaSocket+luarocks-internal HTTPS path: must use `curl` or
- `wget` for HTTPS. **luarocks.org is HTTPS-only**, so this is
- effectively mandatory in some form.
-- **LuaFileSystem** (`lfs`) — for directory operations,
- `chdir`, file attributes. Without it: degraded fallbacks using
- only `io.*` and `os.*`. Some operations become impossible.
-- **lua-bz2** (`bz2`) — for `.bz2` archives. Almost never
- encountered; rocks ship as `.tar.gz` or `.zip`.
-- **LuaPosix** (`posix`) — for chmod and other POSIX ops.
- Without it: fall back to shelling out to `chmod` etc.
-- **md5** — for checksums. luarocks has a pure-Lua fallback.
-- **`luarocks.tools.zip`** (bundled with luarocks itself) — pure
- Lua zip/gzip. No external dependency.
-- **`luarocks.tools.tar`** (bundled) — pure Lua tar.
-
-**Correction:** there is no `--with-lua=embedded` flag — that was a
-hallucination from earlier in the design conversation. luarocks's
-`./configure` accepts `--with-lua=DIR`, `--with-lua-include=DIR`,
-`--with-lua-lib=DIR`, `--with-lua-interpreter=NAME`,
-`--lua-version=VERSION`. These point luarocks at *a* Lua install
-(which can be ours under `$PANTO_HOME`); they don't enable a
-separate "embedded" mode.
-
-**Decision:** minimize luarocks's optional Lua deps. Bootstrap runs
-luarocks in its degraded-but-functional mode using its bundled
-pure-Lua `tools.zip` and `tools.tar`, plus shell-out to `curl` (or
-`wget`) for HTTPS downloads. If any of the optional deps turn out
-to be effectively mandatory in practice, we statically link the
-native C library and embed the Lua wrapper as `@embedFile` in
-`panto` (sibling to Lua itself in `build.zig`). Likely candidates:
-LuaFileSystem (small, pure C wrapper around POSIX, very widely
-used), and possibly LuaSocket+LuaSec if shelling out to `curl`
-proves too clunky.
-
-We also depend on `curl` (or `wget`) being on PATH for downloads.
-This is universal on Unix dev machines and we accept the
-dependency. If it's missing, bootstrap surfaces a clear error.
-
-### Q2: C toolchain on first run
-
-luv has a C component. Building it requires `cc`, `make`, and Lua
-headers. On dev machines (panto's target audience) these are
-universal. On bare end-user machines they aren't.
-
-**Decision:** add a `panto bootstrap` subcommand. It's effectively
-a no-op `panto` invocation that exercises the same
-fetch-and-install path that every normal startup runs, just
-without entering the agent loop afterwards. On a clean machine
-it's where the slow first-run download-and-compile happens with
-visible output; on subsequent runs it's a fast no-op equivalent
-to what every `panto` startup already does.
-
-Every normal `panto` startup runs the same sync logic. The
-`bootstrap` subcommand isn't a *separate* mechanism — it's a way
-to run the sync explicitly without the agent loop, for users who
-want to do setup ahead of time, or for CI/scripted installs.
-
-When the toolchain is missing, surface a friendly error from
-the bootstrap code path before luarocks itself barfs. "You need
-a C compiler and make installed to compile Lua extensions" or
-similar. Native panto features keep working regardless — only
-the Lua tool runtime is gated on successful bootstrap.
-
-### Q3: Lua headers for C rocks
-
-Building C rocks against panto's embedded Lua requires the Lua
-headers to be findable on disk.
-
-**Decision:** drop the headers into `$PANTO_HOME/rocks/lua-X.Y.Z/include/`
-at bootstrap time, where `X.Y.Z` is the Lua version panto is
-built against. Embed the header sources via `@embedFile` (they're
-already available via the `lua_src` build dep). Bootstrap writes
-them out on first run and on any panto upgrade that changes the
-Lua version.
-
-**Why a versioned subdirectory:** rocks compiled against Lua
-5.4.7's headers are not safe to load into a Lua 5.5 interpreter
-(ABI changes happen across minor versions). The whole rock tree
-lives under `$PANTO_HOME/rocks/lua-X.Y.Z/` and each Lua version
-gets its own. A panto upgrade that bumps Lua creates a new tree
-and reinstalls everything against it. The old tree is left in
-place for rollback; users (or a future `panto gc` command) can
-delete stale ones.
-
-Directory layout:
-
-```
-$PANTO_HOME/
- rocks/
- lua-5.4.7/
- include/ ← Lua headers
- share/lua/5.4/ ← installed pure-Lua rocks
- lib/lua/5.4/ ← installed C rocks
- ...luarocks metadata...
- lua-5.5.0/ ← after a future upgrade
- ...
-```
-
-luarocks is invoked with `--tree=$PANTO_HOME/rocks/lua-5.4.7` and
-configured (via its config file or CLI flags) to know that the
-Lua headers live at `$PANTO_HOME/rocks/lua-5.4.7/include/`. The
-tree contains everything needed for that Lua version including
-the headers, which keeps rebuilds reproducible and rollback
-clean.
-
-### Q4: The `lua` interpreter that luarocks expects on PATH
-
-luarocks uses an external `lua` binary for some operations (running
-rockspec build scripts, primarily). It needs to be on PATH and
-needs to be the same version as the embedded interpreter.
-
-**Decision:** `panto lua` is a first-class user-visible subcommand
-that wraps the **upstream `lua.c` standalone interpreter** with
-panto's environment pre-configured.
-
-Mechanically:
-
-- Compile `lua.c` (the upstream standalone interpreter, ~600 lines)
- into the panto binary as a subcommand entry point. Currently
- excluded from `lua_files` in `build.zig`; include it for the
- `lua` subcommand path.
-- `panto lua` arguments pass through to `lua.c`'s normal
- command-line handling (`-i`, `-l`, `-e`, `script.lua args...`,
- etc.). Full standalone-interpreter behavior, not a luarocks-only
- subset.
-- Before handing control to `lua.c`'s main, panto's subcommand
- setup runs the same bootstrap as `panto run` (verify batteries
- installed, install missing rocks, configure `package.path` /
- `package.cpath` to find `$PANTO_HOME/rocks/lua-X.Y.Z/...`).
-- Configure luarocks (via its config file written to
- `$PANTO_HOME/rocks/lua-X.Y.Z/config.lua`) to use
- `<absolute panto path> lua` as its Lua interpreter. luarocks's
- `--with-lua-interpreter=...` flag accepts an executable name;
- we either symlink or use the full argv mechanism.
-
-This gives users a real `lua` they can use to test their
-extensions in panto's environment — `require "luv"` and
-`require "coro-http"` work, plus anything else they've installed
-via `panto lua -e 'require("luarocks.cmd").run(...)'` or similar.
-
-**Side benefit (Q7-related):** until we have a proper user-facing
-`panto rocks install foo` command, `panto lua` is also the user's
-escape hatch for installing extra rocks into `$PANTO_HOME`. We
-can invoke luarocks itself through it.
-
-### Q5: Reproducibility / lockfile
-
-luarocks installs latest-matching by default. For a CLI tool we
-want reproducible: the same panto version installs the same
-battery versions on every fresh machine.
-
-**Decision:** pin exact versions in a panto-internal manifest
-shipped with the binary. No user-facing `panto lock` or
-`panto sync` commands — sync is what every startup (and
-`panto bootstrap`) does automatically.
-
-A manifest file (likely `runtime-batteries.zon` or similar in the
-panto source tree) lists exact versions:
-
-```zig
-.{
- .lua_version = "5.4.7",
- .luarocks_version = "3.12.2",
- .batteries = .{
- .luv = "1.51.0-1",
- .{ .@"coro-fs" = "3.0.4-1" },
- .{ .@"coro-http" = "3.2.1-1" },
- .{ .@"coro-net" = "3.2.1-1" },
- .{ .@"coro-channel" = "3.0.4-1" },
- .{ .@"coro-spawn" = "3.2.1-1" },
- },
-}
-```
-
-Bumping any of these is a deliberate edit + commit + version bump
-of panto itself. Each panto release pins one consistent set.
-
-Bootstrap reads the embedded manifest and ensures the tree matches:
-any rock not present at the pinned version gets installed; any
-stale versions get removed. A panto upgrade that bumps Lua
-creates an entirely new tree (per Q3) and installs everything
-fresh against it.
-
-### Q6: Where in the agent loop does the runtime live
-
-**Decision:** CLI-side. libpanto continues to be native-only and
-Lua-unaware.
-
-The long-lived `lua_State` is constructed in the panto CLI's
-`main` (or a module it calls) before the `Agent` is built.
-Bootstrap runs first, then the runtime loads all discovered
-Lua extensions into the state, then the runtime registers itself
-with the `Agent` as a single `ToolSource` named `panto-lua`.
-The source's `ctx` holds the runtime; the `lua_State` lives
-inside it.
-
-libpanto's only concept is `Tool` and `ToolSource`. It has no
-idea that one of its sources happens to be Lua-backed.
-
-### Q7: What "user extension" actually means in the new world
-
-**Decision (for now):** keep the phase-3 directory-based discovery
-as the only user extension mechanism. Local `.lua` files in
-`~/.config/panto/extensions/` and `./.panto/extensions/`. No
-config file, no `luarocks:foo` references yet.
-
-Directory-discovered extensions get access to whatever's in
-`$PANTO_HOME/rocks/lua-X.Y.Z/` — the runtime batteries (luv,
-coro-*, future additions) and nothing else by default. They can
-`require` any of those modules and pure-Lua code they write
-themselves; that's the supported surface.
-
-**Escape hatch for extra rocks:** users who need a third-party
-Lua library (lua-cjson, lpeg, etc.) for their local extension
-can install it manually via `panto lua` + the embedded luarocks:
-
-```
-panto lua -e 'require("luarocks.cmd").run("install", "lua-cjson")'
-```
-
-or more sugared if we feel like making that look better. The
-rock lands in `$PANTO_HOME/rocks/lua-X.Y.Z/` and survives across
-panto runs.
-
-**Future work (not part of this makeover):** a proper config
-file with `luarocks:panto-subagents`-style references, where
-panto-published extensions can declare their own Lua library
-dependencies in their rockspec and bootstrap installs the
-whole graph automatically. The infrastructure built here
-(luarocks-as-runtime, `$PANTO_HOME` tree, version pinning)
-directly enables this — it's just an orthogonal piece of
-user-facing surface that hasn't been designed yet.
diff --git a/docs/phase-3.md b/docs/phase-3.md
index aeac7fb..5ff052c 100644
--- a/docs/phase-3.md
+++ b/docs/phase-3.md
@@ -1,5 +1,12 @@
# Phase 3: Extension System
+> **Status:** The Lua runtime described in this doc has been **superseded
+> by [`LUA_MAKEOVER.md`](../LUA_MAKEOVER.md)**. The current `panto` CLI
+> uses a long-lived `lua_State` exposed to libpanto as a single
+> `ToolSource`; the per-call `LuaStatePool` / `LuaTool` design described
+> below no longer exists. The **native extension contract (`Tool`) is
+> unchanged** — the rest of this document remains accurate for that path.
+
## Goal
Introduce a native-code tool extension API on `libpanto` and a Lua extension runtime in the `panto` CLI. Together these transform `pantograph` from a chat client into an agent that can act on the world. The extension system is the primary mechanism for adding capability — tools are extensions, not built-ins.
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 3e240d4..55396d1 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -1,35 +1,56 @@
//! The Agent owns the conversation-driving loop: provider streaming +
//! tool dispatch.
//!
-//! In phase 1/2 this was a thin pass-through to the provider. In phase 3
-//! it grows the tool-call loop: after each provider streaming step, the
-//! agent inspects the assistant message for ToolUse blocks, dispatches
-//! the registered handlers (in parallel when there are multiple), and
-//! appends a user message containing the ToolResult blocks back into the
-//! conversation. The loop continues until a turn arrives with no ToolUse
-//! blocks.
+//! On each turn, after the provider streams an assistant message, the
+//! agent inspects it for ToolUse blocks. If any are present, the agent:
+//!
+//! 1. Groups them by their *owning registration* in the registry — a
+//! single `Tool` is its own group; every `ToolSource`-backed tool
+//! whose name maps to the same source forms one group.
+//! 2. Spawns one concurrent task per group via `std.Io.Group`.
+//! A single-`Tool` group runs the tool's `invoke` once; a
+//! `ToolSource` group calls the source's `invoke_batch` with all
+//! of its calls at once. We use `Group.concurrent` (not `async`)
+//! because tool invocations may block on I/O and we need real
+//! concurrency, not just expressed asynchrony.
+//! 3. Awaits the group. ToolResult blocks are assembled in the
+//! *original* call order (i.e. the order the LLM emitted them).
+//! 4. Appends a user message containing the ToolResult blocks back
+//! into the conversation and loops.
+//!
+//! The "thread-safe" promise for single `Tool` registrations is
+//! unchanged. For `ToolSource`-backed tools, the source's runtime
+//! receives all of its calls on one thread per turn, so it can keep a
+//! single-threaded interpreter (Lua, Python, ...) without further
+//! synchronization.
const std = @import("std");
const Allocator = std.mem.Allocator;
-const Thread = std.Thread;
+const Io = std.Io;
const provider_mod = @import("provider.zig");
const conversation = @import("conversation.zig");
const tool_mod = @import("tool.zig");
+const tool_source_mod = @import("tool_source.zig");
const tool_registry_mod = @import("tool_registry.zig");
pub const Tool = tool_mod.Tool;
+pub const ToolSource = tool_source_mod.ToolSource;
pub const ToolRegistry = tool_registry_mod.ToolRegistry;
+const Entry = tool_registry_mod.Entry;
+
pub const Agent = struct {
provider: provider_mod.Provider,
allocator: Allocator,
+ io: Io,
registry: ToolRegistry,
- pub fn init(allocator: Allocator, prov: provider_mod.Provider) Agent {
+ pub fn init(allocator: Allocator, io: Io, prov: provider_mod.Provider) Agent {
return .{
.provider = prov,
.allocator = allocator,
+ .io = io,
.registry = ToolRegistry.init(allocator),
};
}
@@ -39,24 +60,23 @@ pub const Agent = struct {
self.provider.deinit();
}
- /// Register a tool. The agent's registry takes ownership.
+ /// Register a single tool. The agent's registry takes ownership.
pub fn registerTool(self: *Agent, tool: Tool) !void {
try self.registry.register(tool);
}
- /// Remove a tool by name. No-op if not registered.
+ /// Register a tool source. The agent's registry takes ownership.
+ pub fn registerToolSource(self: *Agent, src: ToolSource) !void {
+ try self.registry.registerSource(src);
+ }
+
+ /// Remove a tool by name. No-op if not registered or if the name
+ /// belongs to a source.
pub fn unregisterTool(self: *Agent, name: []const u8) void {
self.registry.unregister(name);
}
/// Drive the conversation forward until the model stops calling tools.
- ///
- /// A single `runStep` invocation may call the provider multiple times
- /// if the model chains tool calls. Each provider call streams a new
- /// assistant message into `conv`; if that message contains ToolUse
- /// blocks the agent dispatches them concurrently, appends a user
- /// message of ToolResult blocks, and loops. The loop terminates when
- /// the provider's most recent response has no ToolUse blocks.
pub fn runStep(
self: *Agent,
conv: *conversation.Conversation,
@@ -68,23 +88,17 @@ pub const Agent = struct {
const last = conv.messages.items[conv.messages.items.len - 1];
std.debug.assert(last.role == .assistant);
- // Defense-in-depth: if the provider committed an assistant
- // message with zero content blocks, something went wrong
- // upstream that wasn't surfaced as a provider error (e.g. a
- // mid-stream provider error that an older codepath swallowed,
- // or a model that genuinely returned nothing). Either way the
- // turn made no observable progress — surface it instead of
- // silently dropping back to the prompt.
+ // Defense-in-depth: a provider that silently committed an
+ // empty assistant message means the turn made no observable
+ // progress. Surface it instead of looping back to the prompt.
if (last.content.items.len == 0) return error.EmptyAssistantResponse;
if (!hasToolUseBlock(last)) return;
try self.dispatchToolCalls(conv, last);
- // Loop: feed the ToolResult message back to the provider.
}
}
- /// Returns true if the message contains at least one ToolUse block.
fn hasToolUseBlock(msg: conversation.Message) bool {
for (msg.content.items) |block| {
if (block == .ToolUse) return true;
@@ -92,92 +106,97 @@ pub const Agent = struct {
return false;
}
- /// Dispatch every ToolUse block in `assistant_msg` concurrently, then
- /// append a single user Message containing all ToolResult blocks to
- /// `conv` in the same order the tool calls appeared.
+ /// Dispatch every ToolUse block in `assistant_msg`. Groups by owning
+ /// registration; one OS thread per group; results assembled in the
+ /// original call order.
fn dispatchToolCalls(
self: *Agent,
conv: *conversation.Conversation,
assistant_msg: conversation.Message,
) !void {
- // Count tool uses for sizing.
- var n: usize = 0;
+ // Build the flat call list (in original order) and group calls
+ // by owning registration.
+ var calls: std.array_list.Managed(FlatCall) = .init(self.allocator);
+ defer calls.deinit();
+
for (assistant_msg.content.items) |block| {
- if (block == .ToolUse) n += 1;
+ if (block != .ToolUse) continue;
+ const tu = block.ToolUse;
+ const entry = self.registry.lookup(tu.name) orelse {
+ // Unknown tool: abort the turn with a clear error.
+ return error.UnknownTool;
+ };
+ try calls.append(.{
+ .tool_use_id = tu.id,
+ .tool_name = tu.name,
+ .input = tu.input.items,
+ .entry = entry.entry,
+ .result = null,
+ .err = null,
+ });
}
- std.debug.assert(n > 0);
-
- const tasks = try self.allocator.alloc(ToolCallTask, n);
- defer self.allocator.free(tasks);
+ std.debug.assert(calls.items.len > 0);
- // Populate tasks. We borrow ID/name slices from the conversation —
- // the assistant message stays in `conv` throughout dispatch, so
- // these slices remain valid until we copy them into the new
- // ToolResultBlock.
- {
- var i: usize = 0;
- for (assistant_msg.content.items) |block| {
- if (block != .ToolUse) continue;
- const tu = block.ToolUse;
- tasks[i] = .{
- .agent = self,
- .tool_use_id = tu.id,
- .tool_name = tu.name,
- .input = tu.input.items,
- .result = null,
- .err = null,
- };
- i += 1;
- }
+ // Partition into groups. A group's `kind` determines how it
+ // runs; the `member_indices` are positions into `calls` (the
+ // original call order) so we can write back results without
+ // re-ordering.
+ var groups: std.array_list.Managed(Group) = .init(self.allocator);
+ defer {
+ for (groups.items) |*g| g.deinit(self.allocator);
+ groups.deinit();
}
+ try buildGroups(self.allocator, calls.items, &groups);
- // Spawn one thread per tool call. `std.Thread.spawn` is cheap
- // (sub-millisecond on Linux/macOS) compared to typical tool
- // latency, and `Tool.invoke` is contractually thread-safe, so we
- // fan out without a pool.
- const threads = try self.allocator.alloc(Thread, n);
- defer self.allocator.free(threads);
-
- var spawned: usize = 0;
- var joined = false;
+ // Spawn one concurrent task per group via `std.Io.Group`.
+ // Single-tool groups run the tool's vtable; source groups run
+ // the source's `invoke_batch`. We use `concurrent` rather than
+ // `async` because tool work may block on I/O — under a
+ // single-threaded `Io` `async` would deadlock; `concurrent`
+ // forces real concurrency (or `error.ConcurrencyUnavailable`).
+ var task_group: Io.Group = .init;
+ // `cancel` is idempotent with `await`; if anything below this
+ // point errors before we successfully `await`, this releases
+ // the group's resources.
+ defer task_group.cancel(self.io);
errdefer {
- // Join any in-flight threads so they don't outlive `tasks`.
- if (!joined) for (threads[0..spawned]) |t| t.join();
- for (tasks) |*task| {
- if (task.result) |r| self.allocator.free(r);
+ for (calls.items) |*c| {
+ if (c.result) |r| self.allocator.free(r);
}
}
- for (tasks, 0..) |*task, idx| {
- threads[idx] = try Thread.spawn(.{}, runToolTask, .{task});
- spawned += 1;
+ for (groups.items) |*g| {
+ try task_group.concurrent(self.io, runGroup, .{ self, g, calls.items });
}
- for (threads[0..spawned]) |t| t.join();
- joined = true;
+ // `error.Canceled` here means cancellation propagated into this
+ // dispatch from above; surface it like any other error.
+ try task_group.await(self.io);
- // Build the user ToolResult message. From here on we own all
- // result byte slices; transfer them into ToolResultBlocks.
+ // Assemble ToolResult blocks in original call order. If any
+ // call errored, prefer to abort the turn — but only after the
+ // standard errdefer above has freed remaining results.
var content: std.ArrayList(conversation.ContentBlock) = .empty;
errdefer {
for (content.items) |*b| b.deinit(self.allocator);
content.deinit(self.allocator);
}
- try content.ensureTotalCapacity(self.allocator, n);
+ try content.ensureTotalCapacity(self.allocator, calls.items.len);
- // If any task failed, prefer to abort the turn — but first move
- // every successful result into a block so it gets freed by the
- // standard cleanup path, and free errored ones eagerly (there
- // are none to move). The errdefer above handles teardown.
var first_err: ?anyerror = null;
- for (tasks) |*task| {
- if (task.err) |e| {
+ for (calls.items) |*c| {
+ if (c.err) |e| {
first_err = e;
continue;
}
- const result_bytes = task.result.?;
- task.result = null; // ownership transferred below
+ const result_bytes = c.result orelse {
+ // Internal error: every successful call should have left
+ // bytes behind. Treat as MissingToolResult.
+ first_err = error.MissingToolResult;
+ continue;
+ };
+ c.result = null; // ownership transferred below
- const id_copy = try self.allocator.dupe(u8, task.tool_use_id);
+ const id_copy = try self.allocator.dupe(u8, c.tool_use_id);
errdefer self.allocator.free(id_copy);
var content_buf: conversation.TextualBlock = .empty;
@@ -193,7 +212,6 @@ pub const Agent = struct {
if (first_err) |e| return e;
- // Wrap the ToolResult blocks into a user Message and append.
try conv.messages.append(self.allocator, .{
.role = .user,
.content = content,
@@ -201,31 +219,158 @@ pub const Agent = struct {
}
};
-/// Per-tool-call work item passed into a worker thread.
-const ToolCallTask = struct {
- agent: *Agent,
+/// One ToolUse, as flattened into the agent's dispatch list. `result`
+/// and `err` are filled in by the worker; exactly one is non-null on
+/// successful task completion.
+const FlatCall = struct {
tool_use_id: []const u8, // borrowed from assistant_msg
tool_name: []const u8, // borrowed from assistant_msg
input: []const u8, // borrowed from assistant_msg
+ entry: Entry,
- /// Owned result bytes from `Tool.invoke`. Allocated with
- /// `agent.allocator`. Transferred into a ToolResultBlock on success.
+ /// Owned result bytes from `Tool.invoke` or `ToolSource.invoke_batch`.
+ /// Allocated with the agent's allocator. Transferred into a
+ /// ToolResultBlock on success.
result: ?[]u8,
- /// If non-null, the tool failed and the turn must abort.
+ /// If non-null, the call failed and the turn must abort.
err: ?anyerror,
};
-fn runToolTask(task: *ToolCallTask) void {
- const tool = task.agent.registry.lookup(task.tool_name) orelse {
- task.err = error.UnknownTool;
+/// One dispatch group. Either a single Tool invocation, or a batch of
+/// calls headed to one ToolSource.
+const Group = union(enum) {
+ single: SingleGroup,
+ source: SourceGroup,
+
+ pub const SingleGroup = struct {
+ tool: Tool,
+ /// Index into the flat calls array.
+ call_index: usize,
+ };
+
+ pub const SourceGroup = struct {
+ source: *ToolSource,
+ /// Indices into the flat calls array. Owned by the group.
+ member_indices: []usize,
+ };
+
+ fn deinit(self: *Group, allocator: Allocator) void {
+ switch (self.*) {
+ .single => {},
+ .source => |sg| allocator.free(sg.member_indices),
+ }
+ }
+};
+
+/// Partition the flat call list into groups. Order of groups is
+/// arbitrary; order within a `source` group preserves the original
+/// call order so that batch results can be written back positionally.
+fn buildGroups(
+ allocator: Allocator,
+ calls: []const FlatCall,
+ out: *std.array_list.Managed(Group),
+) !void {
+ // Map from source pointer to the index of its group in `out`.
+ // Buffers per source, accumulated then frozen into slices.
+ var pending: std.AutoHashMap(*ToolSource, std.array_list.Managed(usize)) =
+ .init(allocator);
+ defer {
+ var it = pending.valueIterator();
+ while (it.next()) |l| l.deinit();
+ pending.deinit();
+ }
+
+ for (calls, 0..) |c, i| {
+ switch (c.entry) {
+ .single => |t| try out.append(.{ .single = .{ .tool = t, .call_index = i } }),
+ .source => |sr| {
+ const gop = try pending.getOrPut(sr.source);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = std.array_list.Managed(usize).init(allocator);
+ }
+ try gop.value_ptr.append(i);
+ },
+ }
+ }
+
+ // Freeze each pending list into a source-group entry. We move
+ // ownership of the indices into `Group.source.member_indices`.
+ var pit = pending.iterator();
+ while (pit.next()) |entry| {
+ const src = entry.key_ptr.*;
+ const indices = try entry.value_ptr.toOwnedSlice();
+ try out.append(.{ .source = .{ .source = src, .member_indices = indices } });
+ }
+}
+
+/// Worker entry point. Runs one group to completion, populating
+/// `calls[i].result` or `calls[i].err` for each member call.
+///
+/// Return type is `void`, which coerces to `Io.Cancelable!void` as
+/// required by `Group.concurrent`. Tool errors are reported via
+/// `FlatCall.err`, not by returning from this function.
+fn runGroup(agent: *Agent, group: *Group, calls: []FlatCall) void {
+ switch (group.*) {
+ .single => |sg| {
+ const i = sg.call_index;
+ const c = &calls[i];
+ const out = sg.tool.vtable.invoke(sg.tool.ctx, c.input, agent.allocator) catch |e| {
+ c.err = e;
+ return;
+ };
+ c.result = out;
+ },
+ .source => |sg| runSourceGroup(agent, sg, calls),
+ }
+}
+
+fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void {
+ const n = sg.member_indices.len;
+
+ const batch_calls = agent.allocator.alloc(tool_source_mod.Call, n) catch |e| {
+ for (sg.member_indices) |i| calls[i].err = e;
+ return;
+ };
+ defer agent.allocator.free(batch_calls);
+
+ const batch_results = agent.allocator.alloc(tool_source_mod.CallResult, n) catch |e| {
+ for (sg.member_indices) |i| calls[i].err = e;
return;
};
- const out = tool.vtable.invoke(tool.ctx, task.input, task.agent.allocator) catch |e| {
- task.err = e;
+ defer agent.allocator.free(batch_results);
+
+ for (sg.member_indices, 0..) |idx, j| {
+ batch_calls[j] = .{
+ .tool_name = calls[idx].tool_name,
+ .input = calls[idx].input,
+ };
+ batch_results[j] = .{ .err = error.SourceDroppedCall };
+ }
+
+ sg.source.vtable.invoke_batch(
+ sg.source.ctx,
+ batch_calls,
+ batch_results,
+ agent.allocator,
+ ) catch |e| {
+ // Whole-batch failure: free any partial successes the source
+ // already wrote, then mark every member as failed.
+ for (batch_results) |r| switch (r) {
+ .ok => |b| agent.allocator.free(b),
+ .err => {},
+ };
+ for (sg.member_indices) |i| calls[i].err = e;
return;
};
- task.result = out;
+
+ // Per-call success/error.
+ for (sg.member_indices, 0..) |i, j| {
+ switch (batch_results[j]) {
+ .ok => |b| calls[i].result = b,
+ .err => |e| calls[i].err = e,
+ }
+ }
}
// -----------------------------------------------------------------------------
@@ -234,18 +379,12 @@ fn runToolTask(task: *ToolCallTask) void {
const testing = std.testing;
-/// A stub Provider that, on each call to `streamStep`, appends a
-/// pre-canned assistant message to the conversation. Used to drive the
-/// agent's tool-call loop without any HTTP plumbing.
const StubProvider = struct {
allocator: Allocator,
scripted: []const ScriptedTurn,
next: usize = 0,
const ScriptedTurn = struct {
- /// Blocks to append as the next assistant message. The producer
- /// owns these — the stub clones them per turn so the conversation
- /// can take ownership.
blocks: []const TestBlock,
};
@@ -314,7 +453,6 @@ const StubProvider = struct {
fn vtDeinit(_: *anyopaque) void {}
};
-/// Simple in-test tool: returns `prefix ++ input`. Used in dispatch tests.
const EchoTool = struct {
prefix_owned: []u8,
name_owned: []u8,
@@ -326,9 +464,11 @@ const EchoTool = struct {
errdefer allocator.free(self.name_owned);
self.prefix_owned = try allocator.dupe(u8, prefix);
return .{
- .name = self.name_owned,
- .description = "echo",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "echo",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -349,14 +489,6 @@ const EchoTool = struct {
}
};
-/// Tool that records the thread it ran on, then participates in a
-/// rendezvous: every invocation must reach the barrier before any can
-/// return. If dispatch is sequential, the first invocation would deadlock
-/// (only one tool runs at a time, never reaching the threshold) — so this
-/// test only passes when invocations run truly concurrently.
-///
-/// The barrier is bounded by a spin-with-yield with a wall-time ceiling
-/// of 5 seconds; failure to reach quorum surfaces as an `error.BarrierTimeout`.
const BarrierTool = struct {
name_owned: []u8,
barrier: *Barrier,
@@ -375,9 +507,11 @@ const BarrierTool = struct {
self.name_owned = try allocator.dupe(u8, name);
self.barrier = barrier;
return .{
- .name = self.name_owned,
- .description = "barrier",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "barrier",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -392,9 +526,6 @@ const BarrierTool = struct {
self.barrier.thread_ids[arrived].store(std.Thread.getCurrentId(), .release);
}
- // Spin-with-yield until everyone has arrived. ~5s ceiling at the
- // typical yield granularity is plenty for a 3-way barrier; on a
- // truly single-threaded dispatch this loop never resolves.
var i: usize = 0;
while (self.barrier.arrived.load(.acquire) < self.barrier.target) : (i += 1) {
if (i > 50_000) return error.BarrierTimeout;
@@ -410,7 +541,6 @@ const BarrierTool = struct {
}
};
-/// Tool whose invoke always errors. Used to verify the turn aborts.
const FailingTool = struct {
name_owned: []u8,
@@ -419,9 +549,11 @@ const FailingTool = struct {
errdefer allocator.destroy(self);
self.name_owned = try allocator.dupe(u8, name);
return .{
- .name = self.name_owned,
- .description = "fails",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "fails",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -461,9 +593,189 @@ const NoopReceiver = struct {
fn noop6(_: *anyopaque, _: anyerror) void {}
};
+/// A configurable ToolSource for testing the grouped-dispatch path.
+/// Stores every batch it receives so tests can assert "calls X and Y
+/// arrived in the same batch on the same thread".
+const TestSource = struct {
+ name_owned: []u8,
+ decls: []tool_source_mod.ToolDecl,
+ decl_strings: std.array_list.Managed([]u8),
+ /// Sequence of (thread_id, [tool_name; n]) per batch received.
+ /// Only mutated inside `invoke_batch`. Because libpanto guarantees
+ /// at most one outstanding `invoke_batch` per source at any time
+ /// (one batch per turn per source), no synchronization is needed.
+ batches: std.array_list.Managed(Batch),
+ allocator: Allocator,
+
+ const Batch = struct {
+ thread_id: u64,
+ names: std.array_list.Managed([]u8),
+ };
+
+ fn create(
+ allocator: Allocator,
+ source_name: []const u8,
+ tool_names: []const []const u8,
+ ) !ToolSource {
+ const self = try allocator.create(TestSource);
+ errdefer allocator.destroy(self);
+
+ var strings = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (strings.items) |s| allocator.free(s);
+ strings.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try strings.append(name_owned);
+
+ const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try strings.append(n);
+ const d = try allocator.dupe(u8, "test src tool");
+ try strings.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try strings.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .decl_strings = strings,
+ .batches = std.array_list.Managed(Batch).init(allocator),
+ .allocator = allocator,
+ };
+
+ return ToolSource{
+ .name = self.name_owned,
+ .tools = self.decls,
+ .ctx = self,
+ .vtable = &vt,
+ };
+ }
+
+ const vt: ToolSource.VTable = .{
+ .invoke_batch = invokeBatch,
+ .deinit = deinitSrc,
+ };
+
+ fn invokeBatch(
+ ctx: *anyopaque,
+ calls: []const tool_source_mod.Call,
+ results: []tool_source_mod.CallResult,
+ allocator: Allocator,
+ ) anyerror!void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ var batch: Batch = .{
+ .thread_id = std.Thread.getCurrentId(),
+ .names = std.array_list.Managed([]u8).init(self.allocator),
+ };
+ for (calls) |c| {
+ const copy = try self.allocator.dupe(u8, c.tool_name);
+ try batch.names.append(copy);
+ }
+ try self.batches.append(batch);
+
+ for (calls, 0..) |c, i| {
+ results[i] = .{
+ .ok = std.fmt.allocPrint(
+ allocator,
+ "{s}->{s}",
+ .{ c.tool_name, c.input },
+ ) catch |e| {
+ results[i] = .{ .err = e };
+ continue;
+ },
+ };
+ }
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ for (self.decl_strings.items) |s| self.allocator.free(s);
+ self.decl_strings.deinit();
+ for (self.batches.items) |*b| {
+ for (b.names.items) |n| self.allocator.free(n);
+ b.names.deinit();
+ }
+ self.batches.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
+/// A source that always fails the whole batch by returning an error
+/// from invoke_batch (rather than recording per-call errors). Used to
+/// verify libpanto's whole-batch-failure path.
+const FailingSource = struct {
+ name_owned: []u8,
+ decls: []tool_source_mod.ToolDecl,
+ decl_strings: std.array_list.Managed([]u8),
+ allocator: Allocator,
+
+ fn create(allocator: Allocator, source_name: []const u8, tool_names: []const []const u8) !ToolSource {
+ const self = try allocator.create(FailingSource);
+ errdefer allocator.destroy(self);
+
+ var strings = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (strings.items) |s| allocator.free(s);
+ strings.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try strings.append(name_owned);
+
+ const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try strings.append(n);
+ const d = try allocator.dupe(u8, "fails");
+ try strings.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try strings.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .decl_strings = strings,
+ .allocator = allocator,
+ };
+ return ToolSource{ .name = self.name_owned, .tools = self.decls, .ctx = self, .vtable = &vt };
+ }
+
+ const vt: ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc };
+
+ fn invokeBatch(
+ _: *anyopaque,
+ _: []const tool_source_mod.Call,
+ _: []tool_source_mod.CallResult,
+ _: Allocator,
+ ) anyerror!void {
+ return error.SourceExploded;
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *FailingSource = @ptrCast(@alignCast(ctx));
+ for (self.decl_strings.items) |s| self.allocator.free(s);
+ self.decl_strings.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
test "registerTool and lookup via registry" {
var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} };
- var agent = Agent.init(testing.allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(testing.allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "ECHO:"));
@@ -473,7 +785,10 @@ test "registerTool and lookup via registry" {
test "duplicate registerTool returns error" {
var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} };
- var agent = Agent.init(testing.allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(testing.allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "A:"));
@@ -495,7 +810,10 @@ test "runStep dispatches a tool call and loops to a final text turn" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(allocator, "echo", "ECHO:"));
@@ -507,7 +825,6 @@ test "runStep dispatches a tool call and loops to a final text turn" {
var recv = NoopReceiver.make();
try agent.runStep(&conv, &recv);
- // user, assistant(tool_use), user(tool_result), assistant(text)
try testing.expectEqual(@as(usize, 4), conv.messages.items.len);
try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role);
@@ -527,10 +844,6 @@ test "runStep dispatches a tool call and loops to a final text turn" {
test "runStep dispatches multiple tool calls in parallel" {
const allocator = testing.allocator;
- // Use a barrier: each tool must wait until all three have arrived
- // before returning. If dispatch were sequential, the first tool
- // would hit its iteration ceiling and `error.BarrierTimeout`. Reaching
- // the barrier proves all three ran concurrently.
var barrier: BarrierTool.Barrier = .{ .target = 3 };
const scripted = [_]StubProvider.ScriptedTurn{
@@ -544,7 +857,10 @@ test "runStep dispatches multiple tool calls in parallel" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try BarrierTool.create(allocator, "barrierA", &barrier));
@@ -558,14 +874,12 @@ test "runStep dispatches multiple tool calls in parallel" {
var recv = NoopReceiver.make();
try agent.runStep(&conv, &recv);
- // Each tool produced one ToolResult, in original order.
const tr_msg = conv.messages.items[2];
try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id);
try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id);
try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id);
- // And the three calls happened on three distinct threads.
const t0 = barrier.thread_ids[0].load(.acquire);
const t1 = barrier.thread_ids[1].load(.acquire);
const t2 = barrier.thread_ids[2].load(.acquire);
@@ -580,11 +894,13 @@ test "runStep propagates tool errors and aborts the turn" {
.{ .blocks = &.{
.{ .ToolUse = .{ .id = "x", .name = "boom", .input = "" } },
} },
- // Second turn should never run.
.{ .blocks = &.{.{ .Text = "should-not-see" }} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try FailingTool.create(allocator, "boom"));
@@ -596,8 +912,6 @@ test "runStep propagates tool errors and aborts the turn" {
var recv = NoopReceiver.make();
try testing.expectError(error.ToolExploded, agent.runStep(&conv, &recv));
- // Conversation has user + assistant(tool_use). No ToolResult message
- // was appended because the dispatch errored before append.
try testing.expectEqual(@as(usize, 2), conv.messages.items.len);
}
@@ -610,7 +924,10 @@ test "runStep errors UnknownTool when the model calls something unregistered" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -628,7 +945,10 @@ test "runStep with no tool calls returns after one provider step" {
.{ .blocks = &.{.{ .Text = "hi" }} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -643,17 +963,16 @@ test "runStep with no tool calls returns after one provider step" {
}
test "runStep surfaces EmptyAssistantResponse when provider commits an empty message" {
- // Mirrors the real-world failure mode where a provider silently ends the
- // turn with no content blocks — e.g. a mid-stream error that an older
- // codepath swallowed. The agent must surface the failure so the user
- // doesn't see the prompt come back with no explanation.
const allocator = testing.allocator;
const scripted = [_]StubProvider.ScriptedTurn{
.{ .blocks = &.{} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -663,3 +982,158 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes
var recv = NoopReceiver.make();
try testing.expectError(error.EmptyAssistantResponse, agent.runStep(&conv, &recv));
}
+
+// ------------ ToolSource tests ------------
+
+test "runStep delivers all source-backed calls in one batch on one thread" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "lua_x", .input = "1" } },
+ .{ .ToolUse = .{ .id = "b", .name = "lua_y", .input = "2" } },
+ .{ .ToolUse = .{ .id = "c", .name = "lua_x", .input = "3" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ // Locate the source and inspect its observed batches.
+ const view = agent.registry.lookup("lua_x") orelse return error.NotFound;
+ const src_ptr = view.entry.source.source;
+ const test_src: *TestSource = @ptrCast(@alignCast(src_ptr.ctx));
+
+ try testing.expectEqual(@as(usize, 1), test_src.batches.items.len);
+ const b = test_src.batches.items[0];
+ try testing.expectEqual(@as(usize, 3), b.names.items.len);
+ try testing.expectEqualStrings("lua_x", b.names.items[0]);
+ try testing.expectEqualStrings("lua_y", b.names.items[1]);
+ try testing.expectEqualStrings("lua_x", b.names.items[2]);
+
+ // ToolResults arrived in the original call order.
+ const tr_msg = conv.messages.items[2];
+ try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
+ try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_x->1", tr_msg.content.items[0].ToolResult.content.items);
+ try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_y->2", tr_msg.content.items[1].ToolResult.content.items);
+ try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_x->3", tr_msg.content.items[2].ToolResult.content.items);
+}
+
+test "runStep: distinct sources run on distinct threads in parallel" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "src_a_t", .input = "" } },
+ .{ .ToolUse = .{ .id = "b", .name = "src_b_t", .input = "" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"}));
+ try agent.registerToolSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"}));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ const view_a = agent.registry.lookup("src_a_t") orelse return error.NotFound;
+ const view_b = agent.registry.lookup("src_b_t") orelse return error.NotFound;
+ const sa: *TestSource = @ptrCast(@alignCast(view_a.entry.source.source.ctx));
+ const sb: *TestSource = @ptrCast(@alignCast(view_b.entry.source.source.ctx));
+
+ try testing.expectEqual(@as(usize, 1), sa.batches.items.len);
+ try testing.expectEqual(@as(usize, 1), sb.batches.items.len);
+ // The two sources ran on distinct OS threads.
+ try testing.expect(sa.batches.items[0].thread_id != sb.batches.items[0].thread_id);
+}
+
+test "runStep: source whole-batch error aborts the turn" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "fa", .input = "" } },
+ .{ .ToolUse = .{ .id = "b", .name = "fb", .input = "" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "never" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("kaboom");
+
+ var recv = NoopReceiver.make();
+ try testing.expectError(error.SourceExploded, agent.runStep(&conv, &recv));
+
+ // Conversation stops at user + assistant(tool_use). No ToolResult appended.
+ try testing.expectEqual(@as(usize, 2), conv.messages.items.len);
+}
+
+test "runStep: mixed single Tools and source-backed tools coexist in one turn" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "single", .input = "X" } },
+ .{ .ToolUse = .{ .id = "b", .name = "src_t1", .input = "Y" } },
+ .{ .ToolUse = .{ .id = "c", .name = "src_t2", .input = "Z" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerTool(try EchoTool.create(allocator, "single", "S:"));
+ try agent.registerToolSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ const tr_msg = conv.messages.items[2];
+ try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
+ try testing.expectEqualStrings("S:X", tr_msg.content.items[0].ToolResult.content.items);
+ try testing.expectEqualStrings("src_t1->Y", tr_msg.content.items[1].ToolResult.content.items);
+ try testing.expectEqualStrings("src_t2->Z", tr_msg.content.items[2].ToolResult.content.items);
+}
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 355b3ce..708ede5 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -68,11 +68,11 @@ pub fn serializeRequest(
while (it.next()) |t| {
try s.beginObject();
try s.objectField("name");
- try s.write(t.name);
+ try s.write(t.decl.name);
try s.objectField("description");
- try s.write(t.description);
+ try s.write(t.decl.description);
try s.objectField("input_schema");
- try writeRawJson(&s, t.schema_json);
+ try writeRawJson(&s, t.decl.schema_json);
try s.endObject();
}
try s.endArray();
@@ -734,9 +734,11 @@ fn makeStaticTool(
schema: []const u8,
) tool_mod.Tool {
return .{
- .name = name,
- .description = description,
- .schema_json = schema,
+ .decl = .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ },
.ctx = &static_tool_ctx_sentinel,
.vtable = &StaticToolVT.v,
};
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig
index 7ca9546..9531131 100644
--- a/libpanto/src/openai_chat_json.zig
+++ b/libpanto/src/openai_chat_json.zig
@@ -89,12 +89,12 @@ pub fn serializeRequest(
try s.objectField("function");
try s.beginObject();
try s.objectField("name");
- try s.write(t.name);
+ try s.write(t.decl.name);
try s.objectField("description");
- try s.write(t.description);
+ try s.write(t.decl.description);
try s.objectField("parameters");
// Emit the tool's JSON Schema verbatim.
- try writeRawJson(&s, t.schema_json);
+ try writeRawJson(&s, t.decl.schema_json);
try s.endObject();
try s.endObject();
}
@@ -580,9 +580,11 @@ fn makeStaticTool(
schema: []const u8,
) tool_mod.Tool {
return .{
- .name = name,
- .description = description,
- .schema_json = schema,
+ .decl = .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ },
.ctx = &static_tool_ctx_sentinel,
.vtable = &StaticToolVT.v,
};
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index bfc814b..5dacfef 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -4,10 +4,15 @@ pub const agent = @import("agent.zig");
pub const config = @import("config.zig");
pub const sse = @import("sse.zig");
pub const tool = @import("tool.zig");
+pub const tool_source = @import("tool_source.zig");
pub const tool_registry = @import("tool_registry.zig");
// Re-exports for ergonomic embedder use.
pub const Tool = tool.Tool;
+pub const ToolSource = tool_source.ToolSource;
+pub const ToolDecl = tool_source.ToolDecl;
+pub const ToolCall = tool_source.Call;
+pub const ToolCallResult = tool_source.CallResult;
pub const ToolRegistry = tool_registry.ToolRegistry;
// Internal modules. Not part of the public API — callers construct providers
diff --git a/libpanto/src/tool.zig b/libpanto/src/tool.zig
index 1d8d113..d9fb178 100644
--- a/libpanto/src/tool.zig
+++ b/libpanto/src/tool.zig
@@ -6,20 +6,17 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
+const tool_source = @import("tool_source.zig");
-pub const Tool = struct {
- /// Tool name. Borrowed — lifetime is owned by whoever constructs the
- /// `Tool`. Typically the same owner that backs `ctx` (e.g. a LuaTool
- /// adapter, or a static const in a native tool).
- name: []const u8,
-
- /// Human-readable purpose of the tool. Emitted to the LLM alongside the
- /// schema. Borrowed; same lifetime contract as `name`.
- description: []const u8,
+pub const ToolDecl = tool_source.ToolDecl;
- /// JSON Schema for the tool's input, as raw JSON bytes. Emitted verbatim
- /// into provider request bodies. Borrowed; same lifetime contract.
- schema_json: []const u8,
+pub const Tool = struct {
+ /// Metadata: `name`, `description`, `schema_json`. Borrowed — the
+ /// lifetime of every string in `decl` is owned by whoever
+ /// constructs the `Tool`. Typically the same owner that backs
+ /// `ctx` (e.g. an adapter for an out-of-process runtime, or a
+ /// `comptime` static in a native tool).
+ decl: ToolDecl,
/// Opaque context pointer passed back to every vtable call.
ctx: *anyopaque,
@@ -53,9 +50,9 @@ pub const Tool = struct {
/// down. Frees any resources owned by `ctx`, including `ctx`
/// itself if it was heap-allocated.
///
- /// `name`, `description`, and `schema_json` are also typically
- /// owned by the same allocation as `ctx` — the tool's deinit
- /// hook is responsible for freeing them.
+ /// The strings inside `decl` are also typically owned by the
+ /// same allocation as `ctx` — the tool's deinit hook is
+ /// responsible for freeing them.
deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void,
};
};
diff --git a/libpanto/src/tool_registry.zig b/libpanto/src/tool_registry.zig
index 4ade16f..e8cb4d1 100644
--- a/libpanto/src/tool_registry.zig
+++ b/libpanto/src/tool_registry.zig
@@ -1,83 +1,194 @@
-//! Registry of `Tool`s owned by an `Agent`.
+//! Registry of tools owned by an `Agent`.
//!
-//! Tools are keyed by name. The registry takes ownership: on `unregister`
-//! or `deinit`, it calls the tool's `vtable.deinit`.
+//! Two kinds of registration coexist:
//!
-//! Iteration is not synchronized — callers must avoid mutating the registry
-//! during iteration. In the current agent loop this is naturally true: the
-//! provider iterates the registry once at request-build time, and tool
+//! - A single `Tool`: a thread-safe, self-contained handler. The
+//! registry holds one entry keyed by `tool.decl.name`.
+//! - A `ToolSource`: a batch-dispatched runtime that owns many tools.
+//! The registry holds one entry per declared tool, all pointing back
+//! at the same source (different `tool_index` per entry).
+//!
+//! Iteration yields the per-tool metadata as a uniform `ToolView` so
+//! callers (chiefly: provider request serializers) don't need to know
+//! which flavor of registration each tool came from.
+//!
+//! Iteration is not synchronized — callers must avoid mutating the
+//! registry during iteration. In the current agent loop this is naturally
+//! true: the provider iterates once at request-build time, and tool
//! registration only happens at agent setup.
const std = @import("std");
const Allocator = std.mem.Allocator;
const tool_mod = @import("tool.zig");
+const tool_source_mod = @import("tool_source.zig");
+
const Tool = tool_mod.Tool;
+const ToolSource = tool_source_mod.ToolSource;
+const ToolDecl = tool_source_mod.ToolDecl;
+
+/// Tagged registry value. The registry stores one of these per *tool
+/// name*. ToolSources expand to one entry per declared tool, each with a
+/// distinct `tool_index`.
+pub const Entry = union(enum) {
+ single: Tool,
+ source: SourceRef,
+
+ pub const SourceRef = struct {
+ source: *ToolSource,
+ /// Index into `source.tools`.
+ tool_index: usize,
+ };
+};
+
+/// Read-only view of a tool's metadata, uniform across `Tool` and
+/// `ToolSource` registrations. Returned by registry iteration and
+/// lookup.
+pub const ToolView = struct {
+ decl: ToolDecl,
+ /// Which entry this view came from. Carries enough information to
+ /// dispatch the call (single Tool vs source-backed).
+ entry: Entry,
+
+ pub fn name(self: ToolView) []const u8 {
+ return self.decl.name;
+ }
+};
pub const ToolRegistry = struct {
- tools: std.StringHashMap(Tool),
+ /// Per-tool-name entries.
+ entries: std.StringHashMap(Entry),
+ /// Heap-allocated sources, kept in a list so `deinit` can tear each
+ /// down exactly once even though many entries reference a single
+ /// source.
+ sources: std.array_list.Managed(*ToolSource),
allocator: Allocator,
pub fn init(allocator: Allocator) ToolRegistry {
return .{
- .tools = std.StringHashMap(Tool).init(allocator),
+ .entries = std.StringHashMap(Entry).init(allocator),
+ .sources = std.array_list.Managed(*ToolSource).init(allocator),
.allocator = allocator,
};
}
- /// Tear down the registry. Each remaining tool's `vtable.deinit` is
- /// invoked.
+ /// Tear down the registry. Each single `Tool`'s `vtable.deinit` is
+ /// invoked once. Each `ToolSource`'s `vtable.deinit` is invoked once
+ /// (not once per declared tool).
pub fn deinit(self: *ToolRegistry) void {
- var it = self.tools.iterator();
+ var it = self.entries.iterator();
while (it.next()) |entry| {
- const tool = entry.value_ptr.*;
- tool.vtable.deinit(tool.ctx, self.allocator);
+ switch (entry.value_ptr.*) {
+ .single => |t| t.vtable.deinit(t.ctx, self.allocator),
+ .source => {},
+ }
}
- self.tools.deinit();
+ self.entries.deinit();
+
+ for (self.sources.items) |src| {
+ src.vtable.deinit(src.ctx, self.allocator);
+ self.allocator.destroy(src);
+ }
+ self.sources.deinit();
}
- /// Register a tool. The registry takes ownership.
+ /// Register a single tool. The registry takes ownership.
///
/// Returns `error.DuplicateTool` if a tool with the same name is
- /// already registered — in that case the caller's tool is NOT taken
- /// over (the caller is responsible for tearing it down).
+ /// already registered (whether from a single Tool or from a source).
+ /// In the duplicate case the caller's tool is NOT taken over; the
+ /// caller is responsible for tearing it down.
pub fn register(self: *ToolRegistry, tool: Tool) !void {
- const gop = try self.tools.getOrPut(tool.name);
+ const gop = try self.entries.getOrPut(tool.decl.name);
if (gop.found_existing) return error.DuplicateTool;
- gop.value_ptr.* = tool;
+ gop.value_ptr.* = .{ .single = tool };
+ }
+
+ /// Register a tool source. The registry takes ownership of `src` —
+ /// it is heap-copied into the registry's source list and freed at
+ /// deinit.
+ ///
+ /// Returns `error.DuplicateTool` if any of the source's declared
+ /// tools collides with an existing registration. On collision the
+ /// source is NOT taken over (caller still owns it and must tear it
+ /// down) and any tools that *had* been inserted before the collision
+ /// are rolled back.
+ pub fn registerSource(self: *ToolRegistry, src: ToolSource) !void {
+ // First pass: check for any collision before committing.
+ for (src.tools) |decl| {
+ if (self.entries.contains(decl.name)) return error.DuplicateTool;
+ }
+
+ // Allocate the persistent heap copy of the source. From this
+ // point forward, on any failure we must free the allocation and
+ // roll back any entries we inserted.
+ const heap = try self.allocator.create(ToolSource);
+ errdefer self.allocator.destroy(heap);
+ heap.* = src;
+
+ var inserted: usize = 0;
+ errdefer {
+ // Roll back any inserts we made before the failure.
+ for (src.tools[0..inserted]) |decl| {
+ _ = self.entries.remove(decl.name);
+ }
+ }
+
+ for (src.tools, 0..) |decl, i| {
+ const gop = try self.entries.getOrPut(decl.name);
+ if (gop.found_existing) return error.DuplicateTool;
+ gop.value_ptr.* = .{ .source = .{ .source = heap, .tool_index = i } };
+ inserted = i + 1;
+ }
+
+ try self.sources.append(heap);
}
- /// Remove a tool by name. Calls the tool's `vtable.deinit`. No-op if
- /// the name is not registered.
+ /// Remove a single-tool registration by name. Calls the tool's
+ /// `vtable.deinit`. No-op if the name is not registered or if it
+ /// belongs to a source (sources are removed as a unit; not yet
+ /// exposed).
pub fn unregister(self: *ToolRegistry, name: []const u8) void {
- if (self.tools.fetchRemove(name)) |kv| {
- kv.value.vtable.deinit(kv.value.ctx, self.allocator);
+ const entry_ptr = self.entries.getPtr(name) orelse return;
+ switch (entry_ptr.*) {
+ .single => |t| {
+ _ = self.entries.remove(name);
+ t.vtable.deinit(t.ctx, self.allocator);
+ },
+ .source => {}, // ignore — sources tear down at registry deinit
}
}
- /// Look up a tool by name. The returned pointer is invalidated by any
- /// subsequent register/unregister call.
- pub fn lookup(self: *const ToolRegistry, name: []const u8) ?*const Tool {
- return self.tools.getPtr(name);
+ /// Look up a tool by name. Returns a uniform `ToolView`. Pointer
+ /// invariants are the same as `std.StringHashMap.getPtr`: invalidated
+ /// by subsequent register/unregister calls.
+ pub fn lookup(self: *const ToolRegistry, name: []const u8) ?ToolView {
+ const entry = self.entries.get(name) orelse return null;
+ return makeView(entry);
}
pub fn count(self: *const ToolRegistry) usize {
- return self.tools.count();
+ return self.entries.count();
}
- /// Iterate registered tools. Caller must not mutate the registry during
- /// iteration.
pub fn iterator(self: *const ToolRegistry) Iterator {
- return .{ .inner = self.tools.iterator() };
+ return .{ .inner = self.entries.iterator() };
}
pub const Iterator = struct {
- inner: std.StringHashMap(Tool).Iterator,
+ inner: std.StringHashMap(Entry).Iterator,
- pub fn next(self: *Iterator) ?*const Tool {
+ pub fn next(self: *Iterator) ?ToolView {
const entry = self.inner.next() orelse return null;
- return entry.value_ptr;
+ return makeView(entry.value_ptr.*);
}
};
+
+ fn makeView(entry: Entry) ToolView {
+ return switch (entry) {
+ .single => |t| .{ .decl = t.decl, .entry = entry },
+ .source => |sr| .{ .decl = sr.source.tools[sr.tool_index], .entry = entry },
+ };
+ }
};
// -----------------------------------------------------------------------------
@@ -111,9 +222,11 @@ const TestTool = struct {
.schema_owned = schema_owned,
};
return .{
- .name = self.name_owned,
- .description = self.desc_owned,
- .schema_json = self.schema_owned,
+ .decl = .{
+ .name = self.name_owned,
+ .description = self.desc_owned,
+ .schema_json = self.schema_owned,
+ },
.ctx = self,
.vtable = &vt,
};
@@ -139,6 +252,99 @@ const TestTool = struct {
}
};
+/// A minimal source backing N tools. Each tool name maps to a configured
+/// response prefix; invoke_batch returns "<prefix>:<input>" for each
+/// call. Tracks the batch sizes it was called with for inspection.
+const TestSource = struct {
+ name_owned: []u8,
+ decls: []ToolDecl,
+ /// Allocations backing every `decl`'s strings. Freed at deinit.
+ allocations: std.array_list.Managed([]u8),
+ batch_sizes: std.array_list.Managed(usize),
+ allocator: Allocator,
+
+ fn create(
+ allocator: Allocator,
+ source_name: []const u8,
+ tool_names: []const []const u8,
+ ) !ToolSource {
+ const self = try allocator.create(TestSource);
+ errdefer allocator.destroy(self);
+
+ var allocations = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (allocations.items) |s| allocator.free(s);
+ allocations.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try allocations.append(name_owned);
+
+ const decls = try allocator.alloc(ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try allocations.append(n);
+ const d = try allocator.dupe(u8, "test src tool");
+ try allocations.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try allocations.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .allocations = allocations,
+ .batch_sizes = std.array_list.Managed(usize).init(allocator),
+ .allocator = allocator,
+ };
+
+ return ToolSource{
+ .name = self.name_owned,
+ .tools = self.decls,
+ .ctx = self,
+ .vtable = &vt,
+ };
+ }
+
+ const vt: ToolSource.VTable = .{
+ .invoke_batch = invokeBatch,
+ .deinit = deinitSrc,
+ };
+
+ fn invokeBatch(
+ ctx: *anyopaque,
+ calls: []const tool_source_mod.Call,
+ results: []tool_source_mod.CallResult,
+ allocator: Allocator,
+ ) anyerror!void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ try self.batch_sizes.append(calls.len);
+ for (calls, 0..) |call, i| {
+ const buf = std.fmt.allocPrint(
+ allocator,
+ "{s}:{s}",
+ .{ call.tool_name, call.input },
+ ) catch |e| {
+ results[i] = .{ .err = e };
+ continue;
+ };
+ results[i] = .{ .ok = buf };
+ }
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ for (self.allocations.items) |s| self.allocator.free(s);
+ self.allocations.deinit();
+ self.batch_sizes.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
test "register, lookup, count" {
const allocator = testing.allocator;
var reg = ToolRegistry.init(allocator);
@@ -151,7 +357,7 @@ test "register, lookup, count" {
try testing.expect(reg.lookup("echo") != null);
try testing.expect(reg.lookup("ls") != null);
try testing.expect(reg.lookup("missing") == null);
- try testing.expectEqualStrings("echo", reg.lookup("echo").?.name);
+ try testing.expectEqualStrings("echo", reg.lookup("echo").?.decl.name);
}
test "duplicate registration returns error and leaves original in place" {
@@ -200,9 +406,9 @@ test "iterator visits every tool" {
var it = reg.iterator();
while (it.next()) |t| {
- if (std.mem.eql(u8, t.name, "a")) saw_a = true;
- if (std.mem.eql(u8, t.name, "b")) saw_b = true;
- if (std.mem.eql(u8, t.name, "c")) saw_c = true;
+ if (std.mem.eql(u8, t.decl.name, "a")) saw_a = true;
+ if (std.mem.eql(u8, t.decl.name, "b")) saw_b = true;
+ if (std.mem.eql(u8, t.decl.name, "c")) saw_c = true;
}
try testing.expect(saw_a and saw_b and saw_c);
}
@@ -215,3 +421,71 @@ test "deinit frees all remaining tools" {
try reg.register(try TestTool.create(allocator, "y"));
reg.deinit();
}
+
+test "registerSource exposes every declared tool by name" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ const src = try TestSource.create(allocator, "panto-lua", &.{ "alpha", "beta", "gamma" });
+ try reg.registerSource(src);
+
+ try testing.expectEqual(@as(usize, 3), reg.count());
+ const v = reg.lookup("beta") orelse return error.NotFound;
+ try testing.expectEqualStrings("beta", v.decl.name);
+ try testing.expect(v.entry == .source);
+}
+
+test "registerSource: collision with existing single tool aborts and rolls back" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.register(try TestTool.create(allocator, "shared"));
+
+ // Build a source that includes the colliding name. We must tear it
+ // down ourselves on failure.
+ var src = try TestSource.create(allocator, "src", &.{ "first", "shared", "third" });
+ try testing.expectError(error.DuplicateTool, reg.registerSource(src));
+ src.vtable.deinit(src.ctx, allocator);
+
+ // No partial state from the source remains.
+ try testing.expectEqual(@as(usize, 1), reg.count());
+ try testing.expect(reg.lookup("first") == null);
+ try testing.expect(reg.lookup("third") == null);
+}
+
+test "registerSource: collision between two sources" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.registerSource(try TestSource.create(allocator, "a", &.{ "foo", "bar" }));
+
+ var s = try TestSource.create(allocator, "b", &.{ "baz", "foo" });
+ try testing.expectError(error.DuplicateTool, reg.registerSource(s));
+ s.vtable.deinit(s.ctx, allocator);
+
+ try testing.expectEqual(@as(usize, 2), reg.count());
+}
+
+test "source view exposes per-tool metadata uniformly" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.registerSource(try TestSource.create(allocator, "lua", &.{ "x", "y" }));
+ try reg.register(try TestTool.create(allocator, "z"));
+
+ try testing.expectEqual(@as(usize, 3), reg.count());
+
+ // Every entry has the canonical fields populated.
+ var it = reg.iterator();
+ var n: usize = 0;
+ while (it.next()) |v| : (n += 1) {
+ try testing.expect(v.decl.name.len > 0);
+ try testing.expect(v.decl.description.len > 0);
+ try testing.expect(v.decl.schema_json.len > 0);
+ }
+ try testing.expectEqual(@as(usize, 3), n);
+}
diff --git a/libpanto/src/tool_source.zig b/libpanto/src/tool_source.zig
new file mode 100644
index 0000000..bb109d8
--- /dev/null
+++ b/libpanto/src/tool_source.zig
@@ -0,0 +1,100 @@
+//! Batch-dispatched tool extension API: `ToolSource`.
+//!
+//! Where `Tool` is a single, thread-safe handler (one tool, one vtable,
+//! reentrant), `ToolSource` is a single owner of many tools whose runtime
+//! prefers to receive calls in *batches* on a single thread.
+//!
+//! Motivation: Lua. A Lua extension runtime maintains one long-lived
+//! `lua_State` so that module-globals, lazy connection pools, rate
+//! limiters, etc. survive across calls. A single `lua_State` is not safe
+//! for concurrent host entry, so the runtime can't satisfy `Tool`'s
+//! thread-safety contract directly. The runtime *can* dispatch many calls
+//! cooperatively (coroutines + an event loop), but it needs to be told
+//! all of them at once.
+//!
+//! The contract libpanto provides:
+//!
+//! - For a given turn, every `ToolUse` block whose tool name belongs to
+//! a particular source is delivered in a single `invoke_batch` call,
+//! on one thread.
+//! - Distinct sources still execute concurrently (one OS thread per
+//! source per turn), so a Lua source and a native source can run in
+//! parallel.
+//! - Single `Tool` registrations are unchanged. They each get their own
+//! thread when they appear alongside other tool calls in a turn.
+//!
+//! The "thread-safe" promise that `Tool.invoke` carries relaxes to
+//! "coroutine-safe within the source's runtime" for source-backed tools —
+//! enforcement is the source's problem.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+/// Tool metadata: everything the LLM-facing wire needs (name,
+/// description, schema) without an invocation vtable. `ToolSource`s
+/// declare their tools this way because they share a single dispatch
+/// path.
+pub const ToolDecl = struct {
+ name: []const u8,
+ description: []const u8,
+ schema_json: []const u8,
+};
+
+/// One pending invocation passed to `invoke_batch`. Slices borrowed from
+/// the caller for the duration of the call.
+pub const Call = struct {
+ /// Which of the source's declared tools this call targets.
+ tool_name: []const u8,
+ /// Raw JSON bytes the provider sent. Borrowed.
+ input: []const u8,
+};
+
+/// Result for a single call. Mirrors the success/error split of
+/// `Tool.invoke`'s return shape. Owned by the caller-supplied allocator.
+pub const CallResult = union(enum) {
+ /// Owned bytes, freed by libpanto after assembling the ToolResult
+ /// block.
+ ok: []u8,
+ err: anyerror,
+};
+
+/// A grouped tool runtime.
+pub const ToolSource = struct {
+ /// Diagnostic name; surfaced in error messages and logs. Example
+ /// values: `"panto-lua"`, `"panto-python"`. Borrowed; lifetime owned
+ /// by the source.
+ name: []const u8,
+ /// Tool metadata for every tool this source owns. Borrowed.
+ tools: []const ToolDecl,
+ ctx: *anyopaque,
+ vtable: *const VTable,
+
+ pub const VTable = struct {
+ /// libpanto guarantees: for a given turn, every ToolUse call
+ /// whose tool name belongs to this source is delivered in one
+ /// `invoke_batch`, on one thread. Different sources still
+ /// execute in parallel.
+ ///
+ /// `calls` and `results` are parallel arrays of length N.
+ /// `results` is pre-allocated by libpanto; the source fills each
+ /// slot. The source decides internal scheduling — sequential,
+ /// coroutine fan-out, worker pool, etc.
+ ///
+ /// On any uncaught error returned from this function (i.e. the
+ /// fn itself returning an error rather than recording one in a
+ /// `results[i]` slot), libpanto treats the *entire batch* as
+ /// failed: it frees any `ok` slots already filled and aborts the
+ /// turn with that error.
+ invoke_batch: *const fn (
+ ctx: *anyopaque,
+ calls: []const Call,
+ results: []CallResult,
+ allocator: Allocator,
+ ) anyerror!void,
+
+ /// Called when the source is removed from the registry or the
+ /// registry is torn down. Frees any resources owned by `ctx`,
+ /// including `ctx` itself if heap-allocated.
+ deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void,
+ };
+};
diff --git a/pantograph-for-studio-rail-system-200-cm.jpg b/pantograph-for-studio-rail-system-200-cm.jpg
deleted file mode 100644
index e9b2df8..0000000
--- a/pantograph-for-studio-rail-system-200-cm.jpg
+++ /dev/null
Binary files differ
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 158e6b6..2a35d15 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -1,38 +1,34 @@
//! Extension discovery: walk well-known directories, locate Lua extensions,
-//! and register their tools with a `ToolRegistry`.
+//! and load each one into a long-lived `LuaRuntime`.
//!
//! Search order (later entries shadow earlier ones by extension *name*):
//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
//! 2. `./.panto/extensions/` ("project")
//!
-//! Layout per directory: each entry is either
+//! Layout per directory:
//! - `<name>.lua` -- single-file extension; the extension name is
//! the basename without the `.lua` suffix.
-//! - `<name>/init.lua` -- directory extension; the extension name is the
-//! directory name. The directory is added to the
-//! extension's `package.path` so it can `require`
-//! sibling Lua files.
+//! - `<name>/init.lua` -- directory extension; the extension name is
+//! the directory name. The directory is added
+//! to the extension's `package.path` so it can
+//! `require` sibling Lua files.
//!
//! Conflict rules:
-//! - Within a single directory, two entries with the same extension name
+//! - Within one directory, two entries with the same extension name
//! are an error.
//! - Project shadows user by extension name (debug-logged, not an error).
-//! - Tool-name collisions *between* loaded extensions are an error: a tool
-//! name is a contract the LLM relies on, and surprising overrides at
-//! load time are worse than failing fast.
+//! - Tool-name collisions *between* loaded extensions are an error: a
+//! tool name is a contract the LLM relies on.
//!
-//! Symlinks: followed normally (no special handling).
-//! Hidden files: dotfiles (`.foo.lua`) are skipped to leave room for editor
-//! swap files and the like.
+//! Symlinks: followed normally. Dotfiles: skipped.
const std = @import("std");
const panto = @import("panto");
-const lua_tool = @import("lua_tool.zig");
-const lua_bridge = @import("lua_bridge.zig");
+const lua_runtime = @import("lua_runtime.zig");
-const c = lua_bridge.c;
const Allocator = std.mem.Allocator;
const Io = std.Io;
+const LuaRuntime = lua_runtime.LuaRuntime;
/// A discovered extension before loading. Owns its strings.
const Found = struct {
@@ -40,11 +36,9 @@ const Found = struct {
name: []u8,
/// Absolute path to the Lua script to execute.
script_path: []u8,
- /// For directory-style extensions, the directory containing the script
- /// (used to extend `package.path`). Null for single-file extensions.
+ /// For directory-style extensions, the directory containing the script.
package_root: ?[]u8,
- /// Which search-path source this came from. Informational, used for
- /// shadowing log messages and tool-conflict error context.
+ /// Which search-path source this came from.
source: Source,
pub fn deinit(self: *Found, allocator: Allocator) void {
@@ -66,17 +60,16 @@ pub const Source = enum {
}
};
-/// Discover and register every extension found in the standard paths
-/// derived from the environment + current working directory. Returns the
-/// number of *tools* (not extensions) registered.
+/// Discover and load every extension found in the standard paths into
+/// `runtime`. Returns the number of *tools* (not extensions) declared.
///
/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
-/// project directory is always taken as `cwd()/.panto/extensions`.
+/// project directory is always `cwd()/.panto/extensions`.
pub fn discoverAndLoad(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
- registry: *panto.ToolRegistry,
+ runtime: *LuaRuntime,
) !usize {
const user_dir = try userExtensionsDir(allocator, environ_map);
defer if (user_dir) |d| allocator.free(d);
@@ -84,21 +77,18 @@ pub fn discoverAndLoad(
const project_dir = try projectExtensionsDir(allocator, io);
defer allocator.free(project_dir);
- return loadFromDirs(allocator, io, registry, user_dir, project_dir);
+ return loadFromDirs(allocator, io, runtime, user_dir, project_dir);
}
-/// Lower-level entry point: load from explicit user/project paths. Useful
-/// for tests that want deterministic behavior without touching
-/// process-global state (HOME, cwd). Either path may be null; missing
-/// directories on disk are silently skipped.
+/// Lower-level entry point: load from explicit user/project paths.
+/// Either path may be null; missing directories are silently skipped.
pub fn loadFromDirs(
allocator: Allocator,
io: Io,
- registry: *panto.ToolRegistry,
+ runtime: *LuaRuntime,
user_dir: ?[]const u8,
project_dir: ?[]const u8,
) !usize {
- // 1. Collect candidates from both sources, project last so it wins.
var found: std.array_list.Managed(Found) = .init(allocator);
defer {
for (found.items) |*f| f.deinit(allocator);
@@ -108,16 +98,11 @@ pub fn loadFromDirs(
if (user_dir) |d| try scanDir(allocator, io, d, .user, &found);
if (project_dir) |d| try scanDir(allocator, io, d, .project, &found);
- // 2. Apply project-shadows-user by name. The latest occurrence wins.
try applyShadowing(allocator, &found);
- // 3. Load each surviving extension. Tool-name conflicts surface as
- // `ToolRegistry.register` errors and abort startup.
- var total_tools: usize = 0;
+ const before = runtime.toolCount();
for (found.items) |f| {
- const n = loadOne(allocator, registry, f) catch |err| {
- // In test builds, log at warn level so a deliberate failure
- // test doesn't trip the test runner's err-count check.
+ runtime.loadExtension(f.script_path, f.package_root) catch |err| {
if (@import("builtin").is_test) {
std.log.warn(
"extension '{s}' ({s}: {s}) failed to load: {t}",
@@ -132,21 +117,17 @@ pub fn loadFromDirs(
return err;
};
std.log.debug(
- "extension: loaded {d} tool(s) from '{s}' ({s})",
- .{ n, f.name, f.source.label() },
+ "extension: loaded '{s}' ({s})",
+ .{ f.name, f.source.label() },
);
- total_tools += n;
}
- return total_tools;
+ return runtime.toolCount() - before;
}
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
-/// Returns the absolute path of the user extensions directory, or null if
-/// `HOME` is unset and `XDG_CONFIG_HOME` is not provided either. Caller
-/// owns the returned slice.
fn userExtensionsDir(
allocator: Allocator,
environ_map: *const std.process.Environ.Map,
@@ -160,8 +141,6 @@ fn userExtensionsDir(
return null;
}
-/// Returns the absolute path of the project extensions directory.
-/// Caller owns the returned slice.
fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 {
const cwd = try std.process.currentPathAlloc(io, allocator);
defer allocator.free(cwd);
@@ -172,8 +151,6 @@ fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 {
// Directory scanning
// ---------------------------------------------------------------------------
-/// Scan `dir_path` and append any candidate extensions to `out`. Missing
-/// directories are not an error: extensions are an optional feature.
fn scanDir(
allocator: Allocator,
io: Io,
@@ -196,7 +173,6 @@ fn scanDir(
var iter = dir.iterate();
while (try iter.next(io)) |entry| {
- // Skip dotfiles (editor swap files, .DS_Store, hidden dirs, ...).
if (entry.name.len == 0 or entry.name[0] == '.') continue;
const maybe_found: ?Found = switch (entry.kind) {
@@ -206,8 +182,6 @@ fn scanDir(
};
const f = maybe_found orelse continue;
- // Within one directory, duplicate names are an error. (Can happen
- // if both `foo.lua` and `foo/init.lua` exist.)
const gop = try local_names.getOrPut(f.name);
if (gop.found_existing) {
if (@import("builtin").is_test) {
@@ -221,13 +195,10 @@ fn scanDir(
.{ f.name, dir_path },
);
}
- // Free the duplicate's resources before bailing.
var dup = f;
dup.deinit(allocator);
return error.DuplicateExtensionInDirectory;
}
- // Hash map key is borrowed from f.name; we need an independent copy
- // since `out` owns f and we'd otherwise double-free.
gop.key_ptr.* = try allocator.dupe(u8, f.name);
try out.append(f);
@@ -257,8 +228,6 @@ fn classifyFile(
};
}
-/// Treat `entry_name/init.lua` as a directory-style extension if present.
-/// Returns null if no `init.lua` exists inside.
fn classifyDirectory(
allocator: Allocator,
io: Io,
@@ -267,13 +236,12 @@ fn classifyDirectory(
entry_name: []const u8,
source: Source,
) !?Found {
- // Probe for init.lua before doing any allocation.
var sub = parent.openDir(io, entry_name, .{}) catch return null;
defer sub.close(io);
sub.access(io, "init.lua", .{}) catch |err| switch (err) {
error.FileNotFound => return null,
- else => return null, // permission, etc. — quietly skip
+ else => return null,
};
const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
@@ -295,14 +263,6 @@ fn classifyDirectory(
// Shadowing
// ---------------------------------------------------------------------------
-/// In-place: for every name that appears more than once, keep only the
-/// *last* occurrence and drop earlier ones with a debug log.
-///
-/// We do this in two passes. The first pass populates a `name → winning
-/// index` map (last-write-wins by construction). The second pass walks
-/// the list once, partitioning into kept and dropped sets *without*
-/// freeing strings yet — the map still references them via its keys, so
-/// freeing mid-walk would create dangling keys in the hash table.
fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void {
var latest: std.StringHashMap(usize) = .init(allocator);
@@ -335,52 +295,25 @@ fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !v
}
}
- // The map's keys still alias `drop`'s entries; deinit it first so we
- // can then free those entries' strings without leaving dangling keys.
latest.deinit();
for (drop.items) |*f| f.deinit(allocator);
drop.deinit();
- // Replace list contents without re-freeing the entries we kept.
list.clearRetainingCapacity();
list.appendSlice(keep.items) catch unreachable;
keep.deinit();
}
// ---------------------------------------------------------------------------
-// Loading
-// ---------------------------------------------------------------------------
-
-/// Load one discovered extension and register its tools. Returns the
-/// number of tools registered.
-fn loadOne(
- allocator: Allocator,
- registry: *panto.ToolRegistry,
- found: Found,
-) !usize {
- if (found.package_root) |root| {
- return lua_tool.loadExtensionWithPackageRoot(
- allocator,
- registry,
- found.script_path,
- root,
- );
- }
- return lua_tool.loadExtension(allocator, registry, found.script_path);
-}
-
-// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
-/// Helper: write a single file inside `dir` at `sub_path`.
fn writeFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void {
try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content });
}
-/// Helper: create a directory (path may contain separators).
fn makeDir(dir: Io.Dir, sub_path: []const u8) !void {
try dir.createDirPath(testing.io, sub_path);
}
@@ -389,14 +322,6 @@ test "scanDir picks up single-file and directory-style extensions" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- // Layout:
- // ext_root/
- // alpha.lua
- // beta/
- // init.lua
- // helper.lua
- // .ignored.lua (dotfile -> skipped)
- // readme.txt (non-.lua -> skipped)
try makeDir(tmp.dir, "ext_root");
try makeDir(tmp.dir, "ext_root/beta");
try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n");
@@ -405,7 +330,6 @@ test "scanDir picks up single-file and directory-style extensions" {
try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n");
try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n");
- // Absolute path to ext_root.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
const ext_root = path_buf[0..ext_root_len];
@@ -419,7 +343,6 @@ test "scanDir picks up single-file and directory-style extensions" {
try testing.expectEqual(@as(usize, 2), list.items.len);
- // Order is filesystem-dependent; sort by name for stable assertions.
std.mem.sort(Found, list.items, {}, struct {
fn lt(_: void, a: Found, b: Found) bool {
return std.mem.lessThan(u8, a.name, b.name);
@@ -439,7 +362,6 @@ test "duplicate name in same directory is an error" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- // Both `foo.lua` and `foo/init.lua` exist.
try makeDir(tmp.dir, "ext_root/foo");
try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n");
try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n");
@@ -482,7 +404,6 @@ test "applyShadowing keeps the latest occurrence" {
try applyShadowing(testing.allocator, &list);
try testing.expectEqual(@as(usize, 3), list.items.len);
- // "shared" survives once, from the project source.
var shared_count: usize = 0;
var shared_source: ?Source = null;
for (list.items) |f| {
@@ -495,7 +416,7 @@ test "applyShadowing keeps the latest occurrence" {
try testing.expectEqual(@as(?Source, .project), shared_source);
}
-test "loadFromDirs: project shadows user end-to-end" {
+test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
@@ -526,66 +447,19 @@ test "loadFromDirs: project shadows user end-to-end" {
const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
defer testing.allocator.free(proj_path);
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
- const n_tools = try loadFromDirs(
- testing.allocator,
- testing.io,
- &registry,
- user_path,
- proj_path,
- );
+ const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, user_path, proj_path);
try testing.expectEqual(@as(usize, 1), n_tools);
- const tool = registry.lookup("greet") orelse return error.NotRegistered;
- try testing.expectEqualStrings("project version", tool.description);
-
- const out = try tool.vtable.invoke(tool.ctx, "{}", testing.allocator);
- defer testing.allocator.free(out);
- try testing.expectEqualStrings("PROJECT", out);
-}
-
-test "loadFromDirs: directory-style extension can require siblings" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "ext/composer");
- try writeFile(tmp.dir, "ext/composer/util.lua",
- \\local M = {}
- \\function M.shout(s) return s:upper() .. "!" end
- \\return M
- );
- try writeFile(tmp.dir, "ext/composer/init.lua",
- \\local util = require("util")
- \\panto.register_tool {
- \\ name = "shout", description = "uppercase + bang",
- \\ schema = { type = "object", properties = { text = { type = "string" } } },
- \\ handler = function(input) return util.shout(input.text) end,
- \\}
- );
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const ext_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
- const ext_path = try testing.allocator.dupe(u8, path_buf[0..ext_len]);
- defer testing.allocator.free(ext_path);
-
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
-
- const n = try loadFromDirs(
- testing.allocator,
- testing.io,
- &registry,
- null,
- ext_path,
- );
- try testing.expectEqual(@as(usize, 1), n);
-
- const tool = registry.lookup("shout") orelse return error.NotRegistered;
- const out = try tool.vtable.invoke(tool.ctx, "{\"text\":\"hi\"}", testing.allocator);
- defer testing.allocator.free(out);
- try testing.expectEqualStrings("HI!", out);
+ // Invoke the tool through the source and verify the project handler ran.
+ var src = rt.toolSource();
+ const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer testing.allocator.free(results[0].ok);
+ try testing.expectEqualStrings("PROJECT", results[0].ok);
}
test "loadFromDirs: tool-name collision between extensions errors" {
@@ -613,15 +487,9 @@ test "loadFromDirs: tool-name collision between extensions errors" {
const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]);
defer testing.allocator.free(ext_path);
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
- const result = loadFromDirs(
- testing.allocator,
- testing.io,
- &registry,
- null,
- ext_path,
- );
+ const result = loadFromDirs(testing.allocator, testing.io, rt, null, ext_path);
try testing.expectError(error.DuplicateTool, result);
}
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index 3d4a24f..eca5661 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -1,7 +1,7 @@
//! Lua C-API bridge for the panto CLI.
//!
-//! Exposes a `panto` global table inside any `lua_State` we construct, with
-//! a single function:
+//! Exposes a `panto` global table inside any `lua_State` we construct,
+//! with a single function:
//!
//! panto.register_tool {
//! name = "...",
@@ -10,26 +10,29 @@
//! handler = function(input) ... end,
//! }
//!
-//! The single-table-argument form is idiomatic Lua "named arguments". It's
-//! also forward-compatible: future optional fields (examples, version, etc.)
-//! can be added without breaking existing extensions.
+//! The single-table-argument form is idiomatic Lua "named arguments".
+//! It's also forward-compatible: future optional fields (examples,
+//! version, etc.) can be added without breaking existing extensions.
//!
-//! Each call records a registration in a Lua-side table at a fixed registry
-//! slot. The Zig side then reads that table to decide what to do with it:
+//! Each call records a registration in a Lua-side table at a fixed
+//! registry slot (`registrations_key`). The Zig side reads that table
+//! to decide what to do with the entries:
//!
-//! - **Discovery** (`harvestRegistrations`): runs an extension script once at
-//! startup to learn the *names*, *descriptions*, and *schemas* of every
-//! tool it declares. The handler functions are discarded — that throwaway
-//! state will be closed immediately.
+//! - The long-lived `LuaRuntime` (`src/lua_runtime.zig`) loads each
+//! extension into a single `lua_State`, then walks the registrations
+//! table once per loaded script (between loads it calls
+//! `resetRegistrations`). For each entry it copies name /
+//! description / schema, `luaL_ref`s the handler function into the
+//! Lua registry, and records the ref under the tool name.
//!
-//! - **Invocation** (`fetchHandler` + `runHandler`): per tool call, we open
-//! a fresh `lua_State`, re-run the script, then look up the handler by
-//! name in the same registry table.
+//! - On dispatch, the runtime spawns a coroutine, pushes the handler
+//! onto it via `lua_rawgeti(LUA_REGISTRYINDEX, ref)`, pushes the
+//! parsed JSON input, and `lua_resume`s. Top-level extension code
+//! never runs again — only handler bodies.
//!
-//! No `lua_State` pooling, no shared mutable state across calls. Every
-//! `LuaTool.invoke` builds and tears down its own state. This is slow per-
-//! call (~ms of Lua startup) but mechanically the simplest model: there is
-//! nothing that can leak between invocations.
+//! This bridge module itself is stateless beyond the public
+//! `registrations_key` address; it cooperates with whatever runtime
+//! owns the `lua_State`.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -69,7 +72,10 @@ pub const BridgeError = error{
/// The key under which we stash the registrations table in
/// `LUA_REGISTRYINDEX`. Any unique pointer works — we use the address of a
/// module-level `u8` so multiple states all use the same key value.
-var registrations_key: u8 = 0;
+///
+/// Public so the long-lived runtime can poke at it directly when it
+/// needs to harvest entries.
+pub var registrations_key: u8 = 0;
/// A single declared tool, as harvested from a script's top-level call to
/// `panto.register_tool`. All slices reference Lua-owned strings on the
@@ -103,6 +109,15 @@ pub fn install(L: *c.lua_State) void {
c.lua_setglobal(L, "panto");
}
+/// Replace the registrations table with a fresh empty one. Used by the
+/// long-lived runtime between loading distinct extension scripts so it
+/// can harvest only the registrations made by the script just loaded
+/// (not accumulated from prior loads).
+pub fn resetRegistrations(L: *c.lua_State) void {
+ c.lua_createtable(L, 0, 0);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &registrations_key);
+}
+
/// Load and execute a Lua source file in the given state. The file's
/// top-level code typically calls `panto.register_tool(...)` one or more
/// times, populating the registrations table.
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
new file mode 100644
index 0000000..44b64a4
--- /dev/null
+++ b/src/lua_runtime.zig
@@ -0,0 +1,678 @@
+//! Long-lived Lua runtime, registered with libpanto as a single
+//! `ToolSource`.
+//!
+//! This replaces the per-call `lua_State` model of phase 3 (LuaTool +
+//! LuaStatePool). The CLI maintains exactly one `lua_State` for its
+//! entire lifetime. Every extension is loaded into it once; extension
+//! top-level code runs exactly once at startup. Tool handlers are
+//! stored in the Lua registry and looked up by tool name on each call.
+//!
+//! libpanto delivers all tool calls targeting Lua-defined tools in one
+//! `invoke_batch` per turn, on a single thread (see
+//! `libpanto/src/tool_source.zig`). This runtime then runs each call as
+//! a Lua *coroutine*. When (later) we wire in libuv via `luv`, a yield
+//! inside a coroutine returns control to the runtime, which drives
+//! `uv.run()` until any coroutine is resumable.
+//!
+//! For now (step 2 of LUA_MAKEOVER.md): no batteries yet. Each call's
+//! coroutine runs to completion synchronously. A handler that yields
+//! to nothing currently leaves the call permanently suspended — we
+//! surface that as a `LuaHandlerYielded` error so it's at least visible.
+//! Step 4 (install `luv`) and step 5 (wire `coro-*`) make yields
+//! productive.
+//!
+//! Concurrency contract for source-backed tools: "coroutine-safe within
+//! this runtime". Concurrent host entry into the same `lua_State` is
+//! *not* safe; libpanto's grouped-dispatch guarantees this never happens.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const panto = @import("panto");
+const lua_bridge = @import("lua_bridge.zig");
+
+const c = lua_bridge.c;
+const Io = std.Io;
+
+pub const SOURCE_NAME = "panto-lua";
+
+/// Errors produced by the runtime above and beyond bridge errors.
+pub const RuntimeError = error{
+ LuaInitFailed,
+ LuaHandlerNotFound,
+ LuaHandlerYielded,
+ LuaHandlerCrashed,
+ BadHandlerReturn,
+ InputNotJsonObject,
+ OutOfMemory,
+};
+
+/// Owned state for the runtime.
+pub const LuaRuntime = struct {
+ allocator: Allocator,
+ L: *c.lua_State,
+ /// Tool declarations for the `ToolSource`, owned by this runtime.
+ decls: std.array_list.Managed(panto.ToolDecl),
+ /// Backing byte buffers for every string referenced by `decls`.
+ strings: std.array_list.Managed([]u8),
+ /// Map from tool name (borrowed from `decls`) to its handler ref in
+ /// the Lua registry (`luaL_ref` index).
+ handlers: std.StringHashMap(c_int),
+
+ /// Create a new runtime. The `lua_State` is opened, standard libs
+ /// loaded, and the `panto.register_tool` bridge installed.
+ pub fn create(allocator: Allocator) !*LuaRuntime {
+ const self = try allocator.create(LuaRuntime);
+ errdefer allocator.destroy(self);
+
+ const L = c.luaL_newstate() orelse return RuntimeError.LuaInitFailed;
+ errdefer c.lua_close(L);
+ c.luaL_openlibs(L);
+ lua_bridge.install(L);
+
+ self.* = .{
+ .allocator = allocator,
+ .L = L,
+ .decls = std.array_list.Managed(panto.ToolDecl).init(allocator),
+ .strings = std.array_list.Managed([]u8).init(allocator),
+ .handlers = std.StringHashMap(c_int).init(allocator),
+ };
+ return self;
+ }
+
+ /// Tear down the runtime: free every owned string, unref every
+ /// handler, close the Lua state.
+ pub fn deinit(self: *LuaRuntime) void {
+ // Unref handlers so future GCs collect them. Not strictly
+ // necessary since we close the state next, but it documents
+ // intent.
+ var hit = self.handlers.iterator();
+ while (hit.next()) |entry| {
+ c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*);
+ }
+ self.handlers.deinit();
+
+ c.lua_close(self.L);
+
+ self.decls.deinit();
+ for (self.strings.items) |s| self.allocator.free(s);
+ self.strings.deinit();
+ self.allocator.destroy(self);
+ }
+
+ /// Load and execute one Lua extension script in this runtime.
+ ///
+ /// `package_root`, if provided, is prepended to `package.path` so
+ /// `require` finds sibling modules.
+ ///
+ /// All `panto.register_tool` calls in the script run during this
+ /// call. The runtime then harvests the registrations table,
+ /// transfers handler functions into the Lua registry (one `luaL_ref`
+ /// per tool), and records each tool's metadata in `self.decls`.
+ pub fn loadExtension(
+ self: *LuaRuntime,
+ script_path: []const u8,
+ package_root: ?[]const u8,
+ ) !void {
+ const path_z = try self.allocator.dupeZ(u8, script_path);
+ defer self.allocator.free(path_z);
+
+ // Reset the registrations table to empty so we only harvest the
+ // calls made by *this* script (not accumulated from prior ones).
+ // The bridge re-installs the registrations table when called;
+ // we want to call only that subset. Instead of re-installing
+ // everything (which would also reset the panto global, fine),
+ // create a fresh registrations table directly via the bridge.
+ lua_bridge.resetRegistrations(self.L);
+
+ if (package_root) |root| {
+ const root_z = try self.allocator.dupeZ(u8, root);
+ defer self.allocator.free(root_z);
+ try prependPackagePath(self.L, root_z);
+ }
+
+ lua_bridge.loadFile(self.L, path_z) catch |err| {
+ logTopAsError(self.L, "lua: failed to load extension");
+ return err;
+ };
+
+ // Harvest the registrations table into our state.
+ try self.harvestAndStoreHandlers();
+ }
+
+ /// Walk the registrations table that the script just populated.
+ /// For each entry:
+ /// - Copy `name`, `description`, `schema_json` into owned bytes.
+ /// - Pop the `handler` function and `luaL_ref` it into the
+ /// registry; record the ref under `handlers[name]`.
+ /// - Append a `ToolDecl` to `self.decls`.
+ fn harvestAndStoreHandlers(self: *LuaRuntime) !void {
+ const L = self.L;
+
+ // Push the registrations table onto the stack.
+ _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.registrations_key);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+
+ const n: usize = @intCast(c.lua_rawlen(L, -1));
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(L, -1, @intCast(i)); // push record
+ // record at -1; bridge's records are 4-field tables.
+
+ const name = try self.readStringFieldOwned("name");
+ errdefer {
+ // If anything below fails after the name was added to
+ // strings, the global deinit still cleans up; nothing
+ // extra to undo here for the string itself. But we
+ // *do* need to make sure the handlers map and decls
+ // remain consistent. We allocate after the string adds,
+ // so partial state is "string captured but no decl"
+ // — harmless.
+ }
+ const desc = try self.readStringFieldOwned("description");
+ const schema = try self.readStringFieldOwned("schema_json");
+
+ // Pop handler function -> luaL_ref into the registry.
+ _ = c.lua_getfield(L, -1, "handler");
+ if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop handler + record
+ return RuntimeError.LuaHandlerNotFound;
+ }
+ const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
+ // Stack: ..., regs_table, record
+
+ const decl: panto.ToolDecl = .{
+ .name = name,
+ .description = desc,
+ .schema_json = schema,
+ };
+
+ // Duplicate names within the runtime are not allowed —
+ // libpanto will also catch them at registry insertion, but
+ // we want a Lua-side error before we've started talking to
+ // libpanto.
+ const gop = try self.handlers.getOrPut(name);
+ if (gop.found_existing) {
+ c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop record
+ return error.DuplicateTool;
+ }
+ gop.value_ptr.* = ref;
+
+ try self.decls.append(decl);
+
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop record
+ }
+ }
+
+ fn readStringFieldOwned(self: *LuaRuntime, field_name: [:0]const u8) ![]const u8 {
+ const L = self.L;
+ _ = c.lua_getfield(L, -1, field_name.ptr);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (c.lua_type(L, -1) != lua_bridge.T_STRING) return error.BadRegistration;
+ var len: usize = 0;
+ const ptr = c.lua_tolstring(L, -1, &len);
+ if (ptr == null) return error.BadRegistration;
+ const owned = try self.allocator.dupe(u8, ptr[0..len]);
+ try self.strings.append(owned);
+ return owned;
+ }
+
+ /// Build a `ToolSource` that hands `invoke_batch` calls back to
+ /// this runtime. The source's `ctx` is `self`. The runtime keeps
+ /// ownership of `self`'s allocation; libpanto's registry only
+ /// frees `ctx` via the source's `vtable.deinit` (which we make a
+ /// no-op — the runtime is owned by the embedder).
+ ///
+ /// Callers must keep the LuaRuntime alive at least as long as the
+ /// registry holds the source.
+ pub fn toolSource(self: *LuaRuntime) panto.ToolSource {
+ return .{
+ .name = SOURCE_NAME,
+ .tools = self.decls.items,
+ .ctx = self,
+ .vtable = &source_vtable,
+ };
+ }
+
+ /// Number of tools currently declared by extensions loaded into
+ /// this runtime.
+ pub fn toolCount(self: *const LuaRuntime) usize {
+ return self.decls.items.len;
+ }
+};
+
+const source_vtable: panto.ToolSource.VTable = .{
+ .invoke_batch = invokeBatch,
+ .deinit = deinitSrc,
+};
+
+fn deinitSrc(_: *anyopaque, _: Allocator) void {
+ // The runtime is owned by the embedder (main()). It explicitly
+ // calls `runtime.deinit()` after the agent has been torn down.
+ // libpanto's source.deinit here is a no-op.
+}
+
+fn invokeBatch(
+ ctx: *anyopaque,
+ calls: []const panto.ToolCall,
+ results: []panto.ToolCallResult,
+ allocator: Allocator,
+) anyerror!void {
+ const self: *LuaRuntime = @ptrCast(@alignCast(ctx));
+
+ // Step 2 of LUA_MAKEOVER.md: no batteries yet — each call is run
+ // as a coroutine, but the scheduler doesn't drive an event loop.
+ // A handler that yields with no batteries available has nothing
+ // to wake it; we surface that as `LuaHandlerYielded`.
+ //
+ // Once `luv` and the `coro-*` wrappers are installed, this loop
+ // becomes "drive uv.run() until every coroutine is dead/erroring,
+ // then collect results".
+ for (calls, 0..) |call, i| {
+ results[i] = runOneCall(self, call, allocator);
+ }
+}
+
+fn runOneCall(
+ self: *LuaRuntime,
+ call: panto.ToolCall,
+ allocator: Allocator,
+) panto.ToolCallResult {
+ const handler_ref = self.handlers.get(call.tool_name) orelse {
+ return .{ .err = RuntimeError.LuaHandlerNotFound };
+ };
+
+ const out_bytes = invokeCoroutine(self.L, handler_ref, call.input, allocator) catch |e| {
+ return .{ .err = e };
+ };
+ return .{ .ok = out_bytes };
+}
+
+/// Create a fresh coroutine, push the handler + JSON-decoded input as
+/// the resume args, then resume. Returns the handler's return value as
+/// owned JSON bytes (the slot `ok` in CallResult).
+///
+/// Resume outcomes:
+/// - LUA_OK: coroutine returned. Read its top value as the result.
+/// - LUA_YIELD: coroutine yielded. With no event loop installed, we
+/// treat this as an error so the user sees that their handler is
+/// trying to do async I/O that isn't yet supported.
+/// - other (errors): error message is on the coroutine's stack;
+/// copy it to a log line and return LuaHandlerCrashed.
+fn invokeCoroutine(
+ L: *c.lua_State,
+ handler_ref: c_int,
+ input: []const u8,
+ allocator: Allocator,
+) ![]u8 {
+ // Create the coroutine thread. After this, `co` is the child
+ // thread; the parent stack also gains a thread value at the top.
+ const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed;
+ defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the thread when done
+
+ // Push handler from the registry onto the coroutine's stack.
+ _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref));
+ if (c.lua_type(co, -1) != lua_bridge.T_FUNCTION) {
+ return RuntimeError.LuaHandlerNotFound;
+ }
+
+ // Push the parsed JSON input as the resume arg.
+ var arena_state = std.heap.ArenaAllocator.init(allocator);
+ defer arena_state.deinit();
+ try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input);
+
+ // Resume with 1 arg.
+ var nresults: c_int = 0;
+ const status = c.lua_resume(co, L, 1, &nresults);
+
+ switch (status) {
+ c.LUA_OK => {
+ // Coroutine returned. We expect exactly one string return
+ // value (the tool result). If there are zero or extra
+ // values we still try to read top-of-stack.
+ if (nresults < 1) return RuntimeError.BadHandlerReturn;
+ return try lua_bridge.readHandlerResult(co, -1, allocator);
+ },
+ c.LUA_YIELD => {
+ // Nothing to wake this coroutine without an event loop.
+ // Surface the situation so the user knows what's wrong.
+ const msg = "lua: tool handler yielded with no event loop installed (step 4+ of LUA_MAKEOVER.md not yet implemented)";
+ if (@import("builtin").is_test) {
+ std.log.warn("{s}", .{msg});
+ } else {
+ std.log.err("{s}", .{msg});
+ }
+ return RuntimeError.LuaHandlerYielded;
+ },
+ else => {
+ logTopAsError(co, "lua: handler crashed");
+ return RuntimeError.LuaHandlerCrashed;
+ },
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Internals
+// ---------------------------------------------------------------------------
+
+fn prependPackagePath(L: *c.lua_State, root: [:0]const u8) !void {
+ const snippet =
+ \\local root = ...
+ \\package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
+ ;
+ if (c.luaL_loadstring(L, snippet) != 0) {
+ logTopAsError(L, "lua: package.path loader failed to compile");
+ return error.LuaPackagePathLoadFailed;
+ }
+ _ = c.lua_pushlstring(L, root.ptr, root.len);
+ if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: package.path setup failed");
+ return error.LuaPackagePathSetupFailed;
+ }
+}
+
+fn logTopAsError(L: *c.lua_State, prefix: []const u8) void {
+ var len: usize = 0;
+ const msg = c.lua_tolstring(L, -1, &len);
+ const is_test = @import("builtin").is_test;
+ if (msg != null) {
+ if (is_test) {
+ std.log.warn("{s}: {s}", .{ prefix, msg[0..len] });
+ } else {
+ std.log.err("{s}: {s}", .{ prefix, msg[0..len] });
+ }
+ } else {
+ if (is_test) {
+ std.log.warn("{s} (no error message)", .{prefix});
+ } else {
+ std.log.err("{s} (no error message)", .{prefix});
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 {
+ try dir.writeFile(testing.io, .{ .sub_path = name, .data = source });
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try dir.realPathFile(testing.io, name, &buf);
+ return testing.allocator.dupe(u8, buf[0..n]);
+}
+
+test "loadExtension records tool decls" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_tool {
+ \\ name = "greet", description = "Says hi.",
+ \\ schema = { type = "object", properties = { name = { type = "string" } } },
+ \\ handler = function(input) return "hi, " .. input.name end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "greet.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ try rt.loadExtension(path, null);
+ try testing.expectEqual(@as(usize, 1), rt.toolCount());
+ try testing.expectEqualStrings("greet", rt.decls.items[0].name);
+}
+
+test "invokeBatch runs each call through a coroutine and returns the result" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_tool {
+ \\ name = "echo", description = "echoes",
+ \\ schema = { type = "object", properties = { msg = { type = "string" } } },
+ \\ handler = function(input) return "got: " .. input.msg end,
+ \\}
+ \\panto.register_tool {
+ \\ name = "shout", description = "shouts",
+ \\ schema = { type = "object", properties = { msg = { type = "string" } } },
+ \\ handler = function(input) return input.msg:upper() .. "!" end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "ext.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(path, null);
+
+ var src = rt.toolSource();
+
+ const calls = [_]panto.ToolCall{
+ .{ .tool_name = "echo", .input = "{\"msg\":\"hello\"}" },
+ .{ .tool_name = "shout", .input = "{\"msg\":\"hi\"}" },
+ .{ .tool_name = "echo", .input = "{\"msg\":\"again\"}" },
+ };
+ var results: [3]panto.ToolCallResult = .{
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ };
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+
+ defer for (results) |r| switch (r) {
+ .ok => |b| testing.allocator.free(b),
+ .err => {},
+ };
+
+ try testing.expectEqualStrings("got: hello", results[0].ok);
+ try testing.expectEqualStrings("HI!", results[1].ok);
+ try testing.expectEqualStrings("got: again", results[2].ok);
+}
+
+test "module-global state survives across calls in the same runtime" {
+ // This is the headline reason the runtime exists. Verify it.
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\local count = 0
+ \\panto.register_tool {
+ \\ name = "bump", description = "increment counter",
+ \\ schema = { type = "object" },
+ \\ handler = function(input)
+ \\ count = count + 1
+ \\ return tostring(count)
+ \\ end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "counter.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(path, null);
+ var src = rt.toolSource();
+
+ const calls = [_]panto.ToolCall{
+ .{ .tool_name = "bump", .input = "{}" },
+ .{ .tool_name = "bump", .input = "{}" },
+ .{ .tool_name = "bump", .input = "{}" },
+ };
+ var results: [3]panto.ToolCallResult = .{
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ };
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+
+ defer for (results) |r| switch (r) {
+ .ok => |b| testing.allocator.free(b),
+ .err => {},
+ };
+
+ try testing.expectEqualStrings("1", results[0].ok);
+ try testing.expectEqualStrings("2", results[1].ok);
+ try testing.expectEqualStrings("3", results[2].ok);
+
+ // And a second batch keeps the counter going.
+ var more: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(
+ src.ctx,
+ &[_]panto.ToolCall{.{ .tool_name = "bump", .input = "{}" }},
+ &more,
+ testing.allocator,
+ );
+ defer testing.allocator.free(more[0].ok);
+ try testing.expectEqualStrings("4", more[0].ok);
+}
+
+test "handler crash: per-call error surfaces, sibling calls succeed" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_tool {
+ \\ name = "ok", description = "ok",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "fine" end,
+ \\}
+ \\panto.register_tool {
+ \\ name = "boom", description = "bad",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) error("kaboom") end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "mix.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(path, null);
+ var src = rt.toolSource();
+
+ const calls = [_]panto.ToolCall{
+ .{ .tool_name = "ok", .input = "{}" },
+ .{ .tool_name = "boom", .input = "{}" },
+ .{ .tool_name = "ok", .input = "{}" },
+ };
+ var results: [3]panto.ToolCallResult = .{
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ };
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer for (results) |r| switch (r) {
+ .ok => |b| testing.allocator.free(b),
+ .err => {},
+ };
+
+ try testing.expectEqualStrings("fine", results[0].ok);
+ try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerCrashed), results[1].err);
+ try testing.expectEqualStrings("fine", results[2].ok);
+}
+
+test "directory-style extension can require sibling modules" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try tmp.dir.createDirPath(testing.io, "ext");
+
+ try tmp.dir.writeFile(testing.io, .{
+ .sub_path = "ext/util.lua",
+ .data =
+ \\local M = {}
+ \\function M.shout(s) return s:upper() .. "!" end
+ \\return M
+ ,
+ });
+ try tmp.dir.writeFile(testing.io, .{
+ .sub_path = "ext/init.lua",
+ .data =
+ \\local util = require("util")
+ \\panto.register_tool {
+ \\ name = "shout", description = "uppercase + bang",
+ \\ schema = { type = "object", properties = { text = { type = "string" } } },
+ \\ handler = function(input) return util.shout(input.text) end,
+ \\}
+ ,
+ });
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const ext_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
+ const ext_dir = try testing.allocator.dupe(u8, path_buf[0..ext_len]);
+ defer testing.allocator.free(ext_dir);
+
+ const init_path = try std.fs.path.join(testing.allocator, &.{ ext_dir, "init.lua" });
+ defer testing.allocator.free(init_path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(init_path, ext_dir);
+
+ var src = rt.toolSource();
+
+ const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer testing.allocator.free(results[0].ok);
+ try testing.expectEqualStrings("HI!", results[0].ok);
+}
+
+test "yielding handler with no event loop surfaces LuaHandlerYielded" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_tool {
+ \\ name = "sleeper", description = "yields forever",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) coroutine.yield() ; return "never" end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "y.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(path, null);
+ var src = rt.toolSource();
+
+ const calls = [_]panto.ToolCall{.{ .tool_name = "sleeper", .input = "{}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+
+ try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerYielded), results[0].err);
+}
+
+test "loadExtension: duplicate tool name from a second extension errors" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const a =
+ \\panto.register_tool {
+ \\ name = "clash", description = "a",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "a" end,
+ \\}
+ ;
+ const b =
+ \\panto.register_tool {
+ \\ name = "clash", description = "b",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "b" end,
+ \\}
+ ;
+ const pa = try writeTempScript(tmp.dir, "a.lua", a);
+ defer testing.allocator.free(pa);
+ const pb = try writeTempScript(tmp.dir, "b.lua", b);
+ defer testing.allocator.free(pb);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(pa, null);
+
+ try testing.expectError(error.DuplicateTool, rt.loadExtension(pb, null));
+}
diff --git a/src/lua_tool.zig b/src/lua_tool.zig
deleted file mode 100644
index 21b3e51..0000000
--- a/src/lua_tool.zig
+++ /dev/null
@@ -1,418 +0,0 @@
-//! `LuaTool` — adapts a Lua-defined tool to libpanto's `Tool` interface.
-//!
-//! Every call to `invoke` opens a fresh `lua_State`, re-runs the extension
-//! script (which calls `panto.register_tool(...)` and registers its handler
-//! into the registrations table), locates the handler by name, runs it
-//! under `xpcall` with a `debug.traceback` error handler, and tears the
-//! state down before returning. No state is reused across calls.
-//!
-//! This is the simplest possible model: each `invoke` is hermetic. The
-//! consequence is a few milliseconds of Lua startup per call, which is
-//! invisible next to LLM round-trip latency. A pool can be added later
-//! behind the same `LuaTool` interface without changing libpanto.
-
-const std = @import("std");
-const panto = @import("panto");
-const lua_bridge = @import("lua_bridge.zig");
-
-const c = lua_bridge.c;
-const Allocator = std.mem.Allocator;
-
-/// A LuaTool owns all the strings the `Tool` interface borrows (name,
-/// description, schema_json), plus the path to the script it dispatches
-/// to. `vtable.deinit` frees everything including the LuaTool itself.
-pub const LuaTool = struct {
- allocator: Allocator,
- // Owned, NUL-terminated for `luaL_loadfilex`.
- script_path_z: [:0]u8,
- /// Optional directory to prepend to `package.path` so the script can
- /// `require` sibling files. Owned, NUL-terminated. Null for single-file
- /// extensions.
- package_root_z: ?[:0]u8,
- name_owned: []u8,
- description_owned: []u8,
- schema_owned: []u8,
-
- /// Build a LuaTool from a harvested registration plus the script path
- /// it came from. All strings are copied into freshly-allocated bytes
- /// owned by this LuaTool — the source slices can be freed after this
- /// returns.
- pub fn create(
- allocator: Allocator,
- script_path: []const u8,
- name: []const u8,
- description: []const u8,
- schema_json: []const u8,
- package_root: ?[]const u8,
- ) !panto.Tool {
- const self = try allocator.create(LuaTool);
- errdefer allocator.destroy(self);
-
- const script_path_z = try allocator.dupeZ(u8, script_path);
- errdefer allocator.free(script_path_z);
- const name_owned = try allocator.dupe(u8, name);
- errdefer allocator.free(name_owned);
- const description_owned = try allocator.dupe(u8, description);
- errdefer allocator.free(description_owned);
- const schema_owned = try allocator.dupe(u8, schema_json);
- errdefer allocator.free(schema_owned);
- const package_root_z: ?[:0]u8 = if (package_root) |p|
- try allocator.dupeZ(u8, p)
- else
- null;
-
- self.* = .{
- .allocator = allocator,
- .script_path_z = script_path_z,
- .package_root_z = package_root_z,
- .name_owned = name_owned,
- .description_owned = description_owned,
- .schema_owned = schema_owned,
- };
-
- return panto.Tool{
- .name = self.name_owned,
- .description = self.description_owned,
- .schema_json = self.schema_owned,
- .ctx = self,
- .vtable = &vtable,
- };
- }
-
- fn freeAll(self: *LuaTool) void {
- const a = self.allocator;
- a.free(self.script_path_z);
- if (self.package_root_z) |p| a.free(p);
- a.free(self.name_owned);
- a.free(self.description_owned);
- a.free(self.schema_owned);
- a.destroy(self);
- }
-};
-
-const vtable: panto.Tool.VTable = .{
- .invoke = invoke,
- .deinit = deinitTool,
-};
-
-fn invoke(
- ctx: *anyopaque,
- input: []const u8,
- allocator: Allocator,
-) anyerror![]u8 {
- const self: *LuaTool = @ptrCast(@alignCast(ctx));
- return runLuaHandler(self, input, allocator);
-}
-
-fn deinitTool(ctx: *anyopaque, _: Allocator) void {
- const self: *LuaTool = @ptrCast(@alignCast(ctx));
- self.freeAll();
-}
-
-/// Open a fresh Lua state, re-load the script (running `panto.register_tool`),
-/// push the handler for `self.name_owned`, push the parsed input, run under
-/// `xpcall`, and return the result bytes.
-fn runLuaHandler(
- self: *LuaTool,
- input: []const u8,
- out_allocator: Allocator,
-) anyerror![]u8 {
- const L = c.luaL_newstate() orelse return error.LuaInitFailed;
- defer c.lua_close(L);
- c.luaL_openlibs(L);
- lua_bridge.install(L);
-
- if (self.package_root_z) |root| {
- try prependPackagePath(L, root);
- }
-
- // Run the script so register_tool fires.
- lua_bridge.loadFile(L, self.script_path_z) catch |err| {
- logTopAsError(L, "lua: failed to load extension");
- return err;
- };
-
- // Push a traceback handler at the bottom of the upcoming call frame.
- _ = c.lua_getglobal(L, "debug");
- _ = c.lua_getfield(L, -1, "traceback");
- // Replace the `debug` slot with its `traceback` field.
- c.lua_copy(L, -1, -2);
- c.lua_settop(L, c.lua_gettop(L) - 1);
- const errfunc_idx = c.lua_gettop(L);
-
- try lua_bridge.pushHandler(L, self.name_owned);
-
- // Parse the LLM's JSON input into a Lua table and push as argument.
- var arena_state = std.heap.ArenaAllocator.init(out_allocator);
- defer arena_state.deinit();
- try lua_bridge.pushJsonAsLua(L, arena_state.allocator(), input);
-
- // Call handler(input). 1 arg, 1 return value, traceback at errfunc_idx.
- const rc = c.lua_pcallk(L, 1, 1, errfunc_idx, 0, null);
- if (rc != 0) {
- logTopAsError(L, "lua: handler crashed");
- return error.LuaHandlerCrashed;
- }
-
- return lua_bridge.readHandlerResult(L, -1, out_allocator);
-}
-
-/// Best-effort: read the top of the Lua stack as a string and log it under
-/// the given prefix. Always leaves the stack as we found it.
-///
-/// In test builds we log at `warn` instead of `err` so the test runner
-/// doesn't treat expected-failure paths (e.g. a crash-protection test) as
-/// overall test failures. End users still see warnings in normal runs.
-fn logTopAsError(L: *c.lua_State, prefix: []const u8) void {
- var len: usize = 0;
- const msg = c.lua_tolstring(L, -1, &len);
- const is_test = @import("builtin").is_test;
- if (msg != null) {
- if (is_test) {
- std.log.warn("{s}: {s}", .{ prefix, msg[0..len] });
- } else {
- std.log.err("{s}: {s}", .{ prefix, msg[0..len] });
- }
- } else {
- if (is_test) {
- std.log.warn("{s} (no error message)", .{prefix});
- } else {
- std.log.err("{s} (no error message)", .{prefix});
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Standalone discovery helper
-// ---------------------------------------------------------------------------
-
-/// Open a *throwaway* Lua state, run `script_path`, harvest every
-/// `panto.register_tool` call into a slice of `LuaTool`s registered with
-/// the given registry. The state is closed before returning; only the
-/// metadata (name, description, schema) survives. Each tool rebuilds its
-/// own state on every invocation.
-///
-/// Returns the number of tools registered. On any failure, the caller
-/// should treat the extension as not loaded — partial-success cleanup is
-/// the caller's responsibility (typically: surface the error and abort).
-pub fn loadExtension(
- allocator: Allocator,
- registry: *panto.ToolRegistry,
- script_path: []const u8,
-) !usize {
- return loadExtensionImpl(allocator, registry, script_path, null);
-}
-
-/// Like `loadExtension`, but extends `package.path` with `<package_root>/?.lua`
-/// and `<package_root>/?/init.lua` so the script can `require` sibling files.
-/// `package_root` should be the directory containing the script.
-pub fn loadExtensionWithPackageRoot(
- allocator: Allocator,
- registry: *panto.ToolRegistry,
- script_path: []const u8,
- package_root: []const u8,
-) !usize {
- return loadExtensionImpl(allocator, registry, script_path, package_root);
-}
-
-fn loadExtensionImpl(
- allocator: Allocator,
- registry: *panto.ToolRegistry,
- script_path: []const u8,
- package_root: ?[]const u8,
-) !usize {
- const path_z = try allocator.dupeZ(u8, script_path);
- defer allocator.free(path_z);
-
- const L = c.luaL_newstate() orelse return error.LuaInitFailed;
- defer c.lua_close(L);
- c.luaL_openlibs(L);
- lua_bridge.install(L);
-
- // Configure require() for directory-style extensions before running.
- if (package_root) |root| {
- const root_z = try allocator.dupeZ(u8, root);
- defer allocator.free(root_z);
- try prependPackagePath(L, root_z);
- }
-
- lua_bridge.loadFile(L, path_z) catch |err| {
- logTopAsError(L, "lua: failed to load extension");
- return err;
- };
-
- var arena_state = std.heap.ArenaAllocator.init(allocator);
- defer arena_state.deinit();
-
- const regs = try lua_bridge.harvestRegistrations(L, arena_state.allocator());
- for (regs) |r| {
- const tool = try LuaTool.create(
- allocator,
- script_path,
- r.name,
- r.description,
- r.schema_json,
- package_root,
- );
- // If registration fails (e.g. duplicate name), free the tool we just
- // built and propagate the error.
- registry.register(tool) catch |err| {
- tool.vtable.deinit(tool.ctx, allocator);
- return err;
- };
- }
- return regs.len;
-}
-
-/// Prepend `<root>/?.lua` and `<root>/?/init.lua` to `package.path` so that
-/// `require("foo")` resolves against files next to the extension's
-/// `init.lua`. We prepend (not replace) so the standard library remains
-/// available.
-///
-/// Stack effect: net zero.
-fn prependPackagePath(L: *c.lua_State, root: [:0]const u8) !void {
- // Run a tiny Lua snippet rather than fiddle with the stack manually:
- // string concatenation is so much nicer in Lua.
- const snippet =
- \\local root = ...
- \\package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
- ;
- if (c.luaL_loadstring(L, snippet) != 0) {
- logTopAsError(L, "lua: package.path loader failed to compile");
- return error.LuaPackagePathLoadFailed;
- }
- _ = c.lua_pushlstring(L, root.ptr, root.len);
- if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
- logTopAsError(L, "lua: package.path setup failed");
- return error.LuaPackagePathSetupFailed;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-const Io = std.Io;
-
-fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 {
- try dir.writeFile(testing.io, .{ .sub_path = name, .data = source });
- // Construct an absolute path so luaL_loadfilex finds it regardless of cwd.
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try dir.realPathFile(testing.io, name, &buf);
- return testing.allocator.dupe(u8, buf[0..n]);
-}
-
-test "loadExtension registers tools and invoke runs the handler" {
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
-
- const source =
- \\panto.register_tool {
- \\ name = "greet", description = "Says hi.",
- \\ schema = { type = "object", properties = { name = { type = "string" } } },
- \\ handler = function(input) return "hi, " .. input.name end,
- \\}
- ;
- const path = try writeTempScript(tmp.dir, "greet.lua", source);
- defer testing.allocator.free(path);
-
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
-
- const n = try loadExtension(testing.allocator, &registry, path);
- try testing.expectEqual(@as(usize, 1), n);
-
- const tool = registry.lookup("greet") orelse return error.NotRegistered;
- try testing.expectEqualStrings("greet", tool.name);
- try testing.expectEqualStrings("Says hi.", tool.description);
- try testing.expect(std.mem.indexOf(u8, tool.schema_json, "\"object\"") != null);
-
- // Invoke through the vtable.
- const result = try tool.vtable.invoke(tool.ctx, "{\"name\":\"travis\"}", testing.allocator);
- defer testing.allocator.free(result);
- try testing.expectEqualStrings("hi, travis", result);
-}
-
-test "invoke surfaces handler crashes as LuaHandlerCrashed" {
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
-
- const source =
- \\panto.register_tool {
- \\ name = "boom", description = "crashes",
- \\ schema = { type = "object" },
- \\ handler = function(input) error("kaboom") end,
- \\}
- ;
- const path = try writeTempScript(tmp.dir, "boom.lua", source);
- defer testing.allocator.free(path);
-
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
-
- _ = try loadExtension(testing.allocator, &registry, path);
- const tool = registry.lookup("boom") orelse return error.NotRegistered;
-
- const result = tool.vtable.invoke(tool.ctx, "{}", testing.allocator);
- try testing.expectError(error.LuaHandlerCrashed, result);
-}
-
-test "concurrent invoke: each call gets its own Lua state" {
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
-
- // The handler returns the pointer-as-hex of `_G`, which differs between
- // distinct Lua states. If two threads share a state, two of the four
- // calls will return the same string.
- const source =
- \\panto.register_tool {
- \\ name = "whoami", description = "state id",
- \\ schema = { type = "object" },
- \\ handler = function(input) return tostring(_G) end,
- \\}
- ;
- const path = try writeTempScript(tmp.dir, "whoami.lua", source);
- defer testing.allocator.free(path);
-
- var registry = panto.ToolRegistry.init(testing.allocator);
- defer registry.deinit();
- _ = try loadExtension(testing.allocator, &registry, path);
- const tool = registry.lookup("whoami") orelse return error.NotRegistered;
-
- const Worker = struct {
- tool: *const panto.Tool,
- out: *[]u8,
- err: *?anyerror,
-
- fn run(self: @This()) void {
- const r = self.tool.vtable.invoke(self.tool.ctx, "{}", testing.allocator) catch |e| {
- self.err.* = e;
- return;
- };
- self.out.* = r;
- }
- };
-
- var results: [4][]u8 = .{ undefined, undefined, undefined, undefined };
- var errs: [4]?anyerror = .{ null, null, null, null };
- var threads: [4]std.Thread = undefined;
- for (&threads, 0..) |*t, i| {
- t.* = try std.Thread.spawn(.{}, Worker.run, .{Worker{
- .tool = tool,
- .out = &results[i],
- .err = &errs[i],
- }});
- }
- for (&threads) |t| t.join();
- defer for (results) |r| if (r.len != 0) testing.allocator.free(r);
-
- for (errs) |e| try testing.expectEqual(@as(?anyerror, null), e);
-
- // All four "_G" identifiers should be distinct, proving distinct states.
- for (0..4) |i| {
- for ((i + 1)..4) |j| {
- try testing.expect(!std.mem.eql(u8, results[i], results[j]));
- }
- }
-}
diff --git a/src/main.zig b/src/main.zig
index 56ea2ad..a752a12 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const panto = @import("panto");
const ping_tool = @import("ping_tool.zig");
const lua_bridge = @import("lua_bridge.zig");
-const lua_tool = @import("lua_tool.zig");
+const lua_runtime = @import("lua_runtime.zig");
const extension_loader = @import("extension_loader.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
@@ -12,7 +12,7 @@ const lua = lua_bridge.c;
test {
std.testing.refAllDecls(@This());
_ = lua_bridge;
- _ = lua_tool;
+ _ = lua_runtime;
_ = extension_loader;
}
@@ -220,13 +220,20 @@ pub fn main(init: std.process.Init) !void {
try conv.addSystemMessage("You are a helpful assistant.");
const prov = try panto.provider.Provider.init(alloc, io, config);
- var agent = panto.agent.Agent.init(alloc, prov);
+ var agent = panto.agent.Agent.init(alloc, io, prov);
defer agent.deinit();
// smoke test: register a trivial built-in tool so we can exercise
// the tool-call loop against a real LLM.
try agent.registerTool(ping_tool.tool());
+ // Spin up the long-lived Lua runtime. All Lua extensions load into
+ // one `lua_State`; module-global state survives across calls. The
+ // runtime registers with the agent as a single `ToolSource` named
+ // `panto-lua`.
+ var rt = try lua_runtime.LuaRuntime.create(alloc);
+ defer rt.deinit();
+
// Discover Lua extensions from $XDG_CONFIG_HOME/panto/extensions (or
// $HOME/.config/panto/extensions) and ./.panto/extensions. Project
// entries shadow user entries with the same name; tool-name collisions
@@ -235,13 +242,17 @@ pub fn main(init: std.process.Init) !void {
alloc,
io,
init.environ_map,
- &agent.registry,
+ rt,
) catch |err| {
std.log.err("extension discovery failed: {t}", .{err});
return err;
};
std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
+ if (n_ext_tools > 0) {
+ try agent.registerToolSource(rt.toolSource());
+ }
+
const banner_model: []const u8 = switch (config) {
inline else => |c| c.model,
};
diff --git a/src/ping_tool.zig b/src/ping_tool.zig
index eb212af..d18bc02 100644
--- a/src/ping_tool.zig
+++ b/src/ping_tool.zig
@@ -35,9 +35,11 @@ const SCHEMA_JSON =
/// nothing dereferences it.
pub fn tool() panto.Tool {
return .{
- .name = NAME,
- .description = DESCRIPTION,
- .schema_json = SCHEMA_JSON,
+ .decl = .{
+ .name = NAME,
+ .description = DESCRIPTION,
+ .schema_json = SCHEMA_JSON,
+ },
.ctx = &ctx_sentinel,
.vtable = &vtable,
};