From 1f0915edbe0213e8bc134922f10933468d35a172 Mon Sep 17 00:00:00 2001 From: T Date: Tue, 26 May 2026 20:14:37 -0600 Subject: 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 --- .panto | 1 + LUA_MAKEOVER.md | 524 ------------------ docs/phase-3.md | 7 + libpanto/src/agent.zig | 776 +++++++++++++++++++++------ libpanto/src/anthropic_messages_json.zig | 14 +- libpanto/src/openai_chat_json.zig | 14 +- libpanto/src/root.zig | 5 + libpanto/src/tool.zig | 27 +- libpanto/src/tool_registry.zig | 356 ++++++++++-- libpanto/src/tool_source.zig | 100 ++++ pantograph-for-studio-rail-system-200-cm.jpg | Bin 123641 -> 0 bytes src/extension_loader.zig | 216 ++------ src/lua_bridge.zig | 53 +- src/lua_runtime.zig | 678 +++++++++++++++++++++++ src/lua_tool.zig | 418 --------------- src/main.zig | 19 +- src/ping_tool.zig | 8 +- 17 files changed, 1855 insertions(+), 1361 deletions(-) create mode 120000 .panto delete mode 100644 LUA_MAKEOVER.md create mode 100644 libpanto/src/tool_source.zig delete mode 100644 pantograph-for-studio-rail-system-200-cm.jpg create mode 100644 src/lua_runtime.zig delete mode 100644 src/lua_tool.zig 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 - ` 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); - - // 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; - } + std.debug.assert(calls.items.len > 0); + + // 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(); } - - // 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; + try buildGroups(self.allocator, calls.items, &groups); + + // 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 ":" 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 Binary files a/pantograph-for-studio-rail-system-200-cm.jpg and /dev/null 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: //! - `.lua` -- single-file extension; the extension name is //! the basename without the `.lua` suffix. -//! - `/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. +//! - `/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, - ®istry, - 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, - ®istry, - 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, - ®istry, - 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, ®istrations_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 `/?.lua` -/// and `/?/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 `/?.lua` and `/?/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, ®istry, 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, ®istry, 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, ®istry, 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, }; -- cgit v1.3