diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/overview.md | 6 | ||||
| -rw-r--r-- | docs/phase-3.md | 461 |
2 files changed, 272 insertions, 195 deletions
diff --git a/docs/overview.md b/docs/overview.md index dfc9f0f..916e904 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -95,3 +95,9 @@ These are recorded from the initial ideas but do not yet have phase documents or - **System prompt construction framework** — opinionated system for assembling system prompts from composable parts (templates, project context, extension contributions) - **Google API provider** — native integration with Google's Gemini API (rather than their OpenAI-compatibility layer), unlocking richer capabilities specific to that API shape. Low priority compared to Anthropic and OpenAI support. - **C ABI distribution of libpanto** — `export fn` wrappers exposing libpanto functionality through a C calling convention, enabling external programs to embed or build on pantograph from C, Rust, or other native languages. Not a separate library — the C ABI is a second interface on the same `libpanto` artifact, compiled from `export fn` shims that translate between Zig types and C types. Needed eventually for shared-object extensions (Zig, Rust, C, C++) beyond Lua. + +## Punted + +Deliberate decisions to defer functionality that came up during phase planning but doesn't fit cleanly into the existing phase roadmap. Each one is captured here with enough context to pick up later. + +- **Tool-call cancellation / timeout via process isolation.** First raised in phase 3. There is no clean POSIX mechanism for cancelling a thread mid-execution with a guarantee of no further side effects — `pthread_kill` with SIGKILL terminates the entire process, `pthread_cancel` is widely considered unusable, and signal-based interruption can't safely unwind arbitrary code. Lua's `lua_sethook` provides cooperative cancellation between VM steps but doesn't interrupt handlers blocked in C calls (filesystem, subprocess, network). The mechanism that actually works is **process isolation**: run tool invocations in a helper subprocess, SIGKILL the subprocess on timeout. The intended approach is `fork+exec` into a small `panto-tool-worker` binary that statically links libpanto's extension machinery. libpanto would define the wire protocol (tool name + input bytes over a pipe, result bytes back) and own the fork/exec/timeout/kill orchestration; the embedder supplies the helper binary path. This makes sandboxing a library-level capability available to all embedders, not just the panto CLI. Open design questions when this is picked up: extension loading strategy in the worker (rescan-per-fork vs long-lived worker pool), file descriptor inheritance policy, working directory and environment handling, and how registry contents are communicated to the worker. Until this lands, tool handlers run to completion in-process — if a tool hangs, the user kills `panto`. diff --git a/docs/phase-3.md b/docs/phase-3.md index 1b9d83a..aeac7fb 100644 --- a/docs/phase-3.md +++ b/docs/phase-3.md @@ -2,28 +2,37 @@ ## Goal -Introduce a Lua extension runtime and tool registration/execution, transforming `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. +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. + +This phase also marks the point where the `Agent` abstraction starts earning its keep. Until now, `agent.zig` has been a thin pass-through to the provider. In phase 3 the agent grows into the thing that owns the tool registry and drives the tool-call loop. Providers stay dumb (stream blocks); the agent is what makes a conversation iterative rather than one-shot. ## Deliverable -A working extension system where Lua scripts can register tools and handle tool-use requests. The agent loop detects ToolUse blocks in LLM responses, executes the corresponding tool handlers, and feeds ToolResult blocks back. At the end of this phase, you can: +Two layered deliverables: + +1. **libpanto** — a Zig-native tool extension API. `Agent` exposes `registerTool(Tool)` and runs a tool-call loop in `runStep`. The agent loop detects ToolUse blocks in LLM responses, dispatches to registered tools, feeds ToolResult blocks back, and repeats until the model stops calling tools. libpanto has no awareness of Lua, Python, or any specific extension language. + +2. **panto CLI** — a Lua extension runtime that discovers Lua scripts on disk, loads them, and registers each declared tool with `libpanto` via a `LuaTool` adapter that satisfies libpanto's `Tool` interface. + +At the end of this phase, you can: -- Write a Lua extension that registers a tool and handles invocations. -- Place it in `~/.config/panto/extensions/` (or `.panto/extensions/`) and have `pantograph` discover and load it. +- Embed `libpanto` in a Zig program, implement a `Tool` struct natively, and register it with the agent — no Lua involved. +- Write a Lua extension that registers a tool, place it in `.agent/extensions/` (or `~/.config/panto/extensions/`), and have the `panto` CLI discover and load it. - Have a conversation where the LLM calls your tool and receives the result. - See tool calls execute in parallel when the LLM returns multiple ToolUse blocks. -- See a meaningful error message when an extension crashes, instead of a process abort. +- See a meaningful error message when a Lua extension crashes, instead of a process abort. ## What is usable at the end | Capability | How to exercise it | |---|---| -| Write a tool extension | Create `~/.config/panto/extensions/mytool.lua` calling `panto.register_tool(...)` | -| Discover extensions | Place `.lua` files or directories in extension directories; `pantograph` loads them on startup | -| LLM calls a tool | Ask the LLM to use a registered tool; it emits a ToolUse block; pantograph executes the handler | -| Tool result fed back | The tool handler's return value becomes a ToolResult block sent back to the LLM | +| Native tool registration | In a Zig embedder, construct a `Tool` and call `agent.registerTool(tool)` | +| Write a Lua tool extension | Create `.agent/extensions/mytool.lua` calling `panto.register_tool(...)` | +| Discover Lua extensions | Place `.lua` files or directories in extension directories; `panto` CLI loads them on startup | +| LLM calls a tool | Ask the LLM to use a registered tool; it emits a ToolUse block; the agent dispatches to the registered handler | +| Tool result fed back | The tool's return bytes become a ToolResult block sent back to the LLM | | Parallel tool calls | LLM returns multiple ToolUse blocks; they execute concurrently | -| Extension crash handling | A crashing tool handler prints `the "mytool" extension crashed: <trace>` and aborts the turn | +| Lua crash handling | A crashing Lua handler prints `the "mytool" extension crashed: <trace>` and aborts the turn | ## What is explicitly out of scope @@ -32,287 +41,349 @@ A working extension system where Lua scripts can register tools and handle tool- - C ABI distribution of libpanto for external consumers (future) - GitHub or luarocks extension loaders (future — local filesystem only in phase 3) - Shared-object extensions (future) -- Extension sandboxing beyond `xpcall` crash protection (future) +- Extension sandboxing beyond `xpcall` crash protection in the Lua adapter (future) - Config file for specifying which extensions to load (phase 6 — phase 3 loads everything it discovers) +- Non-Lua extension runtimes (future — the libpanto API is general, but only the Lua adapter ships in phase 3) --- -## Extension Discovery +## libpanto: Native Tool API -### Directory locations +### The `Tool` interface -`pantograph` scans two directories in order: +`libpanto` defines a `Tool` as an opaque-context value with a vtable. This is the boundary between the agent loop and any extension runtime. -1. `.panto/extensions/` — project-local extensions (relative to current working directory) -2. `~/.config/panto/extensions/` — user-level extensions +```zig +pub const Tool = struct { + name: []const u8, // borrowed; lifetime owned by the registrar + schema_json: []const u8, // borrowed JSON Schema bytes; lifetime owned by the registrar + ctx: *anyopaque, + vtable: *const VTable, -### Naming and structure + pub const VTable = struct { + /// Invoke the tool. MUST be thread-safe — the agent may call `invoke` + /// concurrently from multiple threads when the LLM returns multiple + /// ToolUse blocks in a single response. + /// + /// `input` is the raw JSON bytes the provider sent. The tool is + /// responsible for parsing them if it cares about their structure. + /// + /// Returns owned bytes allocated with `allocator`. These bytes become + /// the `content` of the ToolResult block. + /// + /// Returning an error aborts the current turn. The agent will surface + /// the error to the user. Native tool implementations are responsible + /// for catching their own panics — a panic in `invoke` will crash the + /// process. Adapters that bridge to safer languages (Lua, Python, Go) + /// should convert panics/exceptions into errors here. + invoke: *const fn (ctx: *anyopaque, input: []const u8, allocator: std.mem.Allocator) anyerror![]u8, -Extensions are identified by name, derived from their path. Two formats: + /// Called when the tool is removed from the registry or the agent is + /// torn down. Frees any resources owned by `ctx`. + deinit: *const fn (ctx: *anyopaque, allocator: std.mem.Allocator) void, + }; +}; +``` -- **Single-file**: `<name>.lua` → extension name is `<name>` -- **Directory**: `<name>/init.lua` → extension name is `<name>` +The contract: -Names can be hierarchical using dots as separators, mapping to directory nesting: +- **Thread-safety is mandatory.** Tool implementations promise that concurrent `invoke` calls are safe. This pushes the burden onto adapters (where it belongs — the Lua adapter solves it with a `lua_State` pool; a native Zig tool may just be re-entrant) and out of the agent loop. +- **Input is opaque bytes.** libpanto never parses tool input. The tool decides whether to parse JSON, treat it as a string, or ignore it. +- **Output is opaque bytes.** Same principle in reverse. +- **Errors abort the turn.** No partial recovery in phase 3. +- **Panics crash the process.** Native code that wants safety implements it itself. This is trivially obvious from the API surface: it's a function pointer with `anyerror!` return — there's no magic. Adapters for safer languages can wrap panics/exceptions and convert them to errors. -- `utils/json.lua` → extension name is `utils.json` -- `coding/edit/init.lua` → extension name is `coding.edit` +### `Agent` changes -This convention mirrors Lua's `require("a.b.c")` path resolution. Extension sub-modules (e.g., `coding/edit/helpers.lua`) are the extension's internal business — pantograph only loads the top-level entry point (`init.lua` or the single file). +```zig +pub const Agent = struct { + provider: provider.Provider, + allocator: std.mem.Allocator, + registry: ToolRegistry, -### Loading behavior + pub fn init(allocator: Allocator, prov: provider.Provider) Agent { ... } + pub fn deinit(self: *Agent) void { ... } -- Scan both directories recursively. -- Construct extension names from relative paths using dot separators. -- Load each discovered entry point file into a fresh `lua_State`. -- After loading, the extension's top-level code runs, which should call `panto.register_tool(...)` to register its tools. -- Duplicate extension names: project-local takes precedence over user-level. + pub fn registerTool(self: *Agent, tool: Tool) !void { ... } + pub fn unregisterTool(self: *Agent, name: []const u8) void { ... } ---- + pub fn runStep(self: *Agent, conv: *Conversation, receiver: *Receiver) !void { ... } +}; +``` -## Lua Bridge +`registerTool` takes ownership of the `Tool` value (the agent calls `tool.vtable.deinit` on teardown or unregister). Tool name uniqueness is enforced — registering a name that already exists is an error. -The Lua bridge is a Zig module (`lua_bridge.zig`) that registers panto functions into the Lua state and handles translation between Zig types and Lua types. It is compiled into the `panto` binary — it is not a separate library. +### `ToolRegistry` -### Functions exposed to Lua +A small internal module: -#### `panto.register_tool(name, schema, handler)` +``` +ToolRegistry = struct { + tools: std.StringHashMap(Tool), + allocator: Allocator, -Registers a tool with the agent. + fn register(name, tool) !void + fn unregister(name) void + fn lookup(name) ?*const Tool + fn iterator() Iterator // for serializing into provider requests + fn deinit() void // invokes each tool's vtable.deinit +}; +``` -- `name` (string) — tool name, e.g. `"bash"` -- `schema` (table) — tool input schema as a Lua table (JSON Schema format). Serialized to JSON bytes for inclusion in provider requests. -- `handler` (function) — called when the LLM invokes this tool. Receives a single argument: a Lua table parsed from the tool input JSON. Must return a string (the tool result content). +### Agent loop with tools -Example: +`runStep` gains the tool-call loop: -```lua -panto.register_tool("echo", { - type = "object", - properties = { - message = { type = "string", description = "The message to echo back" } - }, - required = { "message" } -}, function(input) - return input.message -end) +``` +runStep(conv, receiver): + loop: + provider.streamStep(conv, receiver) + inspect the assistant message just appended to conv for ToolUse blocks + if none: return — turn complete + for each ToolUse block, in parallel: + tool = registry.lookup(block.name) or error + result_bytes = tool.vtable.invoke(tool.ctx, block.input, allocator) + build a ToolResult block { tool_use_id = block.id, content = result_bytes } + append a user Message containing all ToolResult blocks to conv + continue loop ``` -### Input parsing at the bridge boundary - -Tool input arrives in libpanto as raw JSON bytes (stored in the ToolUseBlock's TextualBlock). At the Lua bridge boundary, libpanto parses these bytes into a Lua table using `std.json`, then passes the table to the handler. This is a convenience service for extension authors — internally, libpanto still treats tool input as opaque bytes. The round-trip guarantee: the JSON bytes the provider sent are faithfully represented in the Lua table. - -### Output from handlers +A single `runStep` may invoke the provider multiple times if the LLM chains tool calls. -The handler returns a string. This string becomes the `content` of a ToolResult block. It is stored as raw bytes; libpanto does not interpret or parse it. +### Parallel execution ---- +When a response contains multiple ToolUse blocks, the agent dispatches them concurrently. Implementation likely uses a small thread pool or `std.Thread.spawn` per call (TBD during implementation). Because `Tool.invoke` is declared thread-safe, the agent loop has no further constraints — it just fans out and joins. -## Tool Registration (Internal) +### Tool serialization in provider requests -When `panto.register_tool()` is called from Lua, the bridge stores: +When the agent calls the provider, the provider receives the registry (or an iterator over it) and emits the tools array. This means provider request serialization gains a tools-emitting branch when the registry is non-empty. +**OpenAI**: +```json +{ + "tools": [ + { "type": "function", + "function": { "name": "echo", "parameters": <schema_json> } } + ] +} ``` -RegisteredTool = struct { - name: []const u8, // owned copy - input_schema: []const u8, // JSON bytes, owned copy (serialized from the Lua table) - lua_handler_ref: i32, // Lua registry reference to the handler function -}; + +**Anthropic**: +```json +{ + "tools": [ + { "name": "echo", "input_schema": <schema_json> } + ] +} ``` -A global tool registry maps tool names to `RegisteredTool` entries. The agent loop consults this registry when it encounters ToolUse blocks. +The `schema_json` bytes are emitted verbatim. Description fields are deferred — `Tool` does not yet carry a description (see open questions). -The `input_schema` bytes are included verbatim in provider requests when tools are listed. Both OpenAI and Anthropic accept JSON Schema objects for tool input definitions. +### ToolUse decoding in responses ---- +Already handled by the existing Receiver callback sequence from phase 1/2: -## Tool Execution +- OpenAI: `delta.tool_calls` → `onBlockStart(.ToolUse, ...)` with `meta.tool_id` and `meta.tool_name`, then `onContentDelta` with JSON argument fragments. +- Anthropic: `content_block_start` with `type: "tool_use"` → `onBlockStart(.ToolUse, ...)`, then `content_block_delta` with `input_json_delta` fragments. -### Agent loop extension +The assembled ToolUseBlock contains `id`, `name`, and `input` (TextualBlock with the full JSON string). -The agent loop gains tool-call handling after each provider response: +### ToolResult encoding in requests +**OpenAI** — each ToolResult block becomes a separate top-level `tool` message: +```json +{ "role": "tool", "tool_call_id": "<id>", "content": "<result>" } ``` -runStep(conversation, receiver): - 1. Call provider.streamStep(conversation, receiver) - 2. Examine the completed message for ToolUse blocks - 3. If ToolUse blocks present: - a. For each ToolUse block, look up the tool in the registry - b. Execute all tool handlers (see parallel execution below) - c. Collect ToolResult blocks - d. Construct a user Message containing the ToolResult blocks - e. Append to conversation - f. Go to step 1 (call provider again with the updated conversation) - 4. If no ToolUse blocks: done — the turn is complete + +**Anthropic** — ToolResult blocks are content blocks on a user message: +```json +{ "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": "<id>", "content": "<result>" } +] } ``` -A single `runStep` may invoke the provider multiple times if the LLM chains tool calls. +--- -### Parallel execution +## panto CLI: Lua Extension Runtime -Multiple ToolUse blocks in a single response are executed concurrently. This is a documented part of the extension API: **tool handlers may be called concurrently in separate threads.** Extension authors must ensure their handlers are thread-safe. +The Lua runtime lives entirely in the CLI. libpanto has no Lua dependency. -Implementation: on-demand `lua_State` pool. +### Extension discovery -``` -LuaStatePool = struct { - states: std.ArrayList(*lua_State), - available: std.BitSet, // which states are free - allocator: std.mem.Allocator, - extension_dirs: []const []const u8, +The CLI scans two directories in order: - pub fn acquire(self) *lua_State // returns an existing free state, or creates a new one - pub fn release(self, *lua_State) // returns state to the pool - pub fn deinit(self) void // destroys all states -}; -``` +1. `.panto/extensions/` — project-local, relative to current working directory +2. `~/.config/panto/extensions/` — user-level -- `acquire()`: if a free state exists, return it. Otherwise, create a fresh `lua_State`, load all discovered extensions into it (so the handler function references are valid), and return it. -- States are created lazily, not pre-allocated. -- Each state has all extensions loaded identically, so any state can handle any tool. -- When tool execution completes, the state is returned to the pool for reuse. +`.panto/` follows the established per-agent pattern (`.claude/`, `.opencode/`, `.pi/`). -### Crash protection +### Naming and structure -Every tool handler invocation is wrapped in `xpcall` with a traceback handler: +- **Single-file**: `<name>.lua` → extension name is `<name>` +- **Directory**: `<name>/init.lua` → extension name is `<name>` -```lua -xpcall(handler_fn, function(err) - return debug.traceback(err) -end, input_table) -``` +Hierarchical names via dot/directory mapping, mirroring `require("a.b.c")`: -If the handler crashes: +- `utils/json.lua` → `utils.json` +- `coding/edit/init.lua` → `coding.edit` -- The error and stack trace are captured as a string. -- pantograph prints: `the "<tool_name>" extension crashed: <trace>` -- The current LLM turn is aborted — no ToolResult is generated for this tool call. -- Other concurrent tool calls in the same batch are not affected (they run in separate `lua_State` instances). +Sub-modules under a directory extension (e.g., `coding/edit/helpers.lua`) are the extension's internal business — the CLI only loads the top-level entry point. ---- +### Loading behavior -## Tool Serialization in Provider Requests +- Recursively scan both directories. +- Construct extension names from relative paths. +- For each unique extension name, load its entry point into the `lua_State` pool (see below). +- Project-local entries shadow user-level entries of the same name. +- Extension top-level code runs at load time; it is expected to call `panto.register_tool(...)`. -When tools are registered, the provider requests must include them. Both providers have a `tools` field in the request body. +### The Lua bridge -### OpenAI +A small Zig module (`lua_bridge.zig`) registers Zig functions into a `lua_State`. The bridge exposes: -``` -{ - "model": ..., - "stream": true, - "messages": [...], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "description": "...", // not yet supported, phase 5+ when we add descriptions - "parameters": { <input_schema> } - } - } - ] -} -``` +#### `panto.register_tool(name, schema, handler)` -### Anthropic +- `name` (string) — tool name, e.g. `"bash"` +- `schema` (table) — JSON Schema as a Lua table. The bridge serializes this to JSON bytes at registration time. +- `handler` (function) — invoked when the LLM calls this tool. Receives a single argument: a Lua table parsed from the input JSON. Must return a string. -``` -{ - "model": ..., - "system": ..., - "stream": true, - "messages": [...], - "tools": [ - { - "name": "echo", - "description": "...", - "input_schema": { <input_schema> } - } - ] -} +```lua +panto.register_tool("echo", { + type = "object", + properties = { + message = { type = "string", description = "The message to echo back" } + }, + required = { "message" } +}, function(input) + return input.message +end) ``` -The `input_schema` bytes stored in the registry are emitted verbatim into the `parameters` (OpenAI) or `input_schema` (Anthropic) field. +The bridge stores the handler in the Lua registry (`luaL_ref`) and constructs a `LuaTool` (see below) that it then registers with the agent. -### ToolUse in responses +### `LuaTool` — the adapter to libpanto -Both providers return tool-call information in their streaming responses. This is already handled by the existing Receiver callback sequence: +```zig +const LuaTool = struct { + pool: *LuaStatePool, + handler_ref: i32, // registry ref to the Lua function + name_owned: []u8, + schema_owned: []u8, -- OpenAI: `delta.tool_calls` triggers `onBlockStart(.ToolUse, ...)` with `meta.tool_id` and `meta.tool_name`, then `onContentDelta` with JSON argument fragments. -- Anthropic: `content_block_start` with `type: "tool_use"` triggers `onBlockStart(.ToolUse, ...)`, then `content_block_delta` with `input_json_delta` fragments. + // Implements Tool.VTable.invoke: + // 1. pool.acquire() → lua_State + // 2. lua_rawgeti(L, LUA_REGISTRYINDEX, handler_ref) — push handler + // 3. parse input bytes into a Lua table via std.json + // 4. xpcall the handler with the table + // 5. read returned string (or capture trace on error) + // 6. pool.release(L) + // 7. return result bytes or error +}; +``` -The assembled ToolUseBlock contains `id`, `name`, and `input` (TextualBlock with the full JSON string). The agent loop reads `name` to look up the registered tool, reads `input.content()` to get the JSON string, and passes it through the Lua bridge. +The adapter is what makes the CLI side responsible for Lua-specific concerns: -### ToolResult in requests +- **JSON → Lua table conversion**: happens here, not in libpanto. +- **`xpcall` crash protection**: wraps the handler call, captures `debug.traceback`, returns an error so libpanto aborts the turn cleanly with `the "<tool_name>" extension crashed: <trace>`. +- **Thread-safety**: provided by the `lua_State` pool. Each concurrent `invoke` checks out a separate state. libpanto's contract is satisfied. -After tool execution, a user Message containing ToolResult blocks is appended to the conversation. Serialization differs by provider: +### `LuaStatePool` + +On-demand pool of `lua_State` instances: -**OpenAI**: Each ToolResult block becomes a separate message: -```json -{ "role": "tool", "tool_call_id": "<tool_use_id>", "content": "<result string>" } ``` +LuaStatePool = struct { + states: std.ArrayList(*lua_State), + available: std.BitSet, + allocator: Allocator, + extension_paths: []const []const u8, // all discovered entry points -**Anthropic**: ToolResult blocks are content blocks on a user message: -```json -{ "role": "user", "content": [ - { "type": "tool_result", "tool_use_id": "...", "content": "..." } -] } + fn acquire() *lua_State // returns a free state, or creates a new one + fn release(L: *lua_State) void // returns L to the pool + fn deinit() void // closes all states +}; ``` +- States are created lazily — no pre-allocation. +- Each new state loads all discovered extensions identically, so any state can handle any tool. +- A state is checked out for the duration of a single `invoke` call. + +There's a coupling here: the handler `luaL_ref` is per-`lua_State`. The pool must guarantee that the same registry slot in every state points at the same handler. The cleanest way is: when an extension is first loaded into the prototype state, the bridge records the registry index. When a new state is constructed, the bridge re-loads all extensions in the same order, producing the same ref indices. This needs care during implementation but is mechanically straightforward. + --- ## Module Changes -### New files +### libpanto -``` -src/lua_bridge.zig // Zig functions registered into Lua state, type translation -src/tool_registry.zig // RegisteredTool storage, lookup by name -src/lua_state_pool.zig // On-demand pool of lua_State instances -src/extension_loader.zig // Directory scanning, extension discovery and loading -``` +New: +- `libpanto/src/tool.zig` — `Tool` and `VTable` definitions +- `libpanto/src/tool_registry.zig` — `ToolRegistry` + +Modified: +- `libpanto/src/agent.zig` — owns registry, gains `registerTool`, drives tool-call loop in `runStep` +- `libpanto/src/provider.zig` — provider receives a way to see registered tools (likely a `*const ToolRegistry` on the agent passed via `streamStep` context, or a callback) +- `libpanto/src/provider_openai_chat.zig` — request serialization emits `tools` array +- `libpanto/src/provider_anthropic_messages.zig` — request serialization emits `tools` array +- `libpanto/src/openai_chat_json.zig` — ToolResult message encoding +- `libpanto/src/anthropic_messages_json.zig` — ToolResult content block encoding +- `libpanto/src/root.zig` — re-exports `Tool`, `ToolRegistry` -### Modified files +### panto CLI -- `agent.zig` — runStep gains the tool-call loop (detect ToolUse → execute → feed results → repeat) -- `provider_openai.zig` — request serialization includes `tools` array when tools are registered -- `provider_anthropic.zig` — request serialization includes `tools` array when tools are registered -- `json.zig` — serialization for ToolResult blocks (OpenAI and Anthropic formats) -- `config.zig` — extension directories added to config +New: +- `src/lua_bridge.zig` — Zig functions registered into Lua states; `panto.register_tool` implementation +- `src/lua_tool.zig` — `LuaTool` adapter implementing libpanto's `Tool` interface +- `src/lua_state_pool.zig` — on-demand `lua_State` pool +- `src/extension_loader.zig` — directory scanning, name construction, loading + +Modified: +- `src/main.zig` — wires extension discovery + loading into agent startup ### External dependency -Lua interpreter linked into the `panto` binary. Zig's build system can fetch and compile Lua from source (Lua is a small C codebase, ~30KLOC). No system dependency required. +Lua 5.4 linked into the `panto` binary. Zig's build system can fetch and compile Lua from source (~30KLOC C). No system dependency required. libpanto does not link Lua. --- ## Testing Strategy -### Unit tests +### Unit tests (libpanto) + +| What | How | +|---|---| +| Tool registration | Construct a native `Tool`, register with `Agent`, verify lookup | +| Duplicate registration | Verify registering the same name twice returns an error | +| Tool dispatch | Hand-craft a conversation with a ToolUse block, run `runStep` against a stub provider, verify the registered tool's `invoke` is called with the right bytes | +| ToolResult round-trip | Verify the ToolResult message appended to the conversation has the right `tool_use_id` and `content` | +| Parallel dispatch | Register two tools with sleep+timestamps, verify they ran concurrently (elapsed wall time < sum of individual times) | +| Provider request serialization | Snapshot tests on OpenAI and Anthropic request bodies with tools registered | + +### Unit tests (panto CLI) | What | How | |---|---| | Extension discovery | Create temp directory with single-file and directory extensions, verify names constructed correctly | -| Tool registration | Load a Lua extension that registers a tool, verify entry appears in registry with correct name and schema | -| Lua bridge input parsing | Feed JSON strings through the bridge, verify correct Lua tables produced | -| Lua bridge output | Call a tool handler that returns a string, verify it becomes a ToolResult with correct content | -| Crash protection | Load an extension whose handler throws an error, verify xpcall catches it and returns trace | +| Project shadows user | Same extension name in both directories — project wins | +| Lua bridge input | Feed JSON bytes through `LuaTool.invoke`, verify the Lua handler sees the expected table | +| Lua bridge output | Handler returns a string, verify it round-trips to bytes | +| `xpcall` crash protection | Extension whose handler throws — verify `invoke` returns an error and includes the trace | +| Concurrent `invoke` | Two threads call `invoke` on the same `LuaTool`, verify they don't share a `lua_State` | -### Integration test (manual) +### Integration tests (manual) -- Write a simple `echo.lua` extension, place it in extension directory -- Start `panto`, ask the LLM to use the echo tool -- Verify the tool is called, result is fed back, LLM continues -- Write a `crash.lua` extension that throws an error -- Verify the crash is caught and printed with context, turn aborts gracefully -- Ask the LLM to use two tools in one response, verify both execute +- `echo.lua` extension: ask the LLM to use it, verify result is fed back, LLM continues. +- `crash.lua` extension: verify the crash is caught, printed, turn aborts gracefully. +- Multi-tool response: ask the LLM to call two tools in one message, verify both execute concurrently. --- -## Open Questions (to resolve during implementation) +## Resolved Decisions -1. **Lua version**: Lua 5.4 is current. Luau (Roblox's fork) has performance improvements but diverges. Stick with standard Lua 5.4 for compatibility with luarocks and existing ecosystem? -2. **Handler timeout**: Should tool handlers have a timeout? A hung tool call blocks the agent loop. Could add a configurable timeout with abort. -3. **Streaming tool results**: Some tools (e.g., `bash` running a long command) produce output incrementally. Phase 3 handlers return a single string. Streaming results would require a different handler interface — possibly a callback the handler calls to emit partial output. Defer to a later phase? -4. **Tool description field**: The `panto.register_tool()` call in phase 3 includes a schema but no explicit description string. Provider APIs require a description. Options: add a `description` parameter, or extract it from the schema. Probably simplest to add it as a parameter. +- **Project-local extension directory**: `.panto/extensions/`, matching `.claude/`, `.opencode/`, `.pi/`. +- **Lua version**: Lua 5.4. LuaJIT is stuck at 5.1 semantics and the JIT speedup is largely irrelevant for tool handlers (which spend most of their time in Zig-side I/O). 5.4 keeps us aligned with current luarocks and Lua documentation. +- **Tool description**: a top-level `description: []const u8` field on `Tool`. The Lua bridge gains it as a parameter: `panto.register_tool(name, description, schema, handler)`. Schema describes input only; description is the human-readable purpose. +- **Provider sees the registry**: `provider.streamStep` gains a `*const ToolRegistry` parameter. Embedders normally pass the same registry on every call. +- **Parallel dispatch**: `std.Thread.spawn` per ToolUse block. Thread creation is sub-millisecond on Linux/macOS; tool calls are orders of magnitude longer. Recycling buys nothing meaningful. +- **Streaming tool results**: deferred, possibly indefinitely. Provider APIs only accept a final result payload, so streaming would be display-only — not worth the vtable complication in phase 3. +- **Handler timeout / cancellation**: deferred. POSIX has no clean way to cancel a thread (`pthread_kill` with SIGKILL terminates the whole process; `pthread_cancel` is unusable in practice; signal-based interruption is not async-signal-safe for arbitrary code). Lua's `lua_sethook` only fires between VM steps, so it can't interrupt a handler blocked in a C call. A real cancellation primitive requires process isolation, which is a phase of its own — see the Punted list in [overview.md](overview.md). Phase 3 ships with the honest contract: **tool handlers run to completion. If a tool hangs, the user kills the `panto` process.** |
