1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
|
# Phase 3: Extension System
## 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.
## 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:
- 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.
- 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.
## 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 |
| 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 |
## What is explicitly out of scope
- Core tools as extensions (phase 5 — `std.read`, `std.write`, etc.)
- Conversation serialization / disk persistence (phase 4)
- 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)
- Config file for specifying which extensions to load (phase 6 — phase 3 loads everything it discovers)
---
## Extension Discovery
### Directory locations
`pantograph` scans two directories in order:
1. `.panto/extensions/` — project-local extensions (relative to current working directory)
2. `~/.config/panto/extensions/` — user-level extensions
### Naming and structure
Extensions are identified by name, derived from their path. Two formats:
- **Single-file**: `<name>.lua` → extension name is `<name>`
- **Directory**: `<name>/init.lua` → extension name is `<name>`
Names can be hierarchical using dots as separators, mapping to directory nesting:
- `utils/json.lua` → extension name is `utils.json`
- `coding/edit/init.lua` → extension name is `coding.edit`
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).
### Loading behavior
- 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.
---
## Lua Bridge
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.
### Functions exposed to Lua
#### `panto.register_tool(name, schema, handler)`
Registers a tool with the agent.
- `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).
Example:
```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)
```
### 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
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.
---
## Tool Registration (Internal)
When `panto.register_tool()` is called from Lua, the bridge stores:
```
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
};
```
A global tool registry maps tool names to `RegisteredTool` entries. The agent loop consults this registry when it encounters ToolUse blocks.
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.
---
## Tool Execution
### Agent loop extension
The agent loop gains tool-call handling after each provider response:
```
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
```
A single `runStep` may invoke the provider multiple times if the LLM chains tool calls.
### Parallel execution
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.
Implementation: on-demand `lua_State` pool.
```
LuaStatePool = struct {
states: std.ArrayList(*lua_State),
available: std.BitSet, // which states are free
allocator: std.mem.Allocator,
extension_dirs: []const []const u8,
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
};
```
- `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.
### Crash protection
Every tool handler invocation is wrapped in `xpcall` with a traceback handler:
```lua
xpcall(handler_fn, function(err)
return debug.traceback(err)
end, input_table)
```
If the handler crashes:
- 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).
---
## Tool Serialization in Provider Requests
When tools are registered, the provider requests must include them. Both providers have a `tools` field in the request body.
### OpenAI
```
{
"model": ...,
"stream": true,
"messages": [...],
"tools": [
{
"type": "function",
"function": {
"name": "echo",
"description": "...", // not yet supported, phase 5+ when we add descriptions
"parameters": { <input_schema> }
}
}
]
}
```
### Anthropic
```
{
"model": ...,
"system": ...,
"stream": true,
"messages": [...],
"tools": [
{
"name": "echo",
"description": "...",
"input_schema": { <input_schema> }
}
]
}
```
The `input_schema` bytes stored in the registry are emitted verbatim into the `parameters` (OpenAI) or `input_schema` (Anthropic) field.
### ToolUse in responses
Both providers return tool-call information in their streaming responses. This is already handled by the existing Receiver callback sequence:
- 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.
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.
### ToolResult in requests
After tool execution, a user Message containing ToolResult blocks is appended to the conversation. Serialization differs by provider:
**OpenAI**: Each ToolResult block becomes a separate message:
```json
{ "role": "tool", "tool_call_id": "<tool_use_id>", "content": "<result string>" }
```
**Anthropic**: ToolResult blocks are content blocks on a user message:
```json
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "...", "content": "..." }
] }
```
---
## Module Changes
### New files
```
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
```
### Modified files
- `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
### 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.
---
## Testing Strategy
### Unit tests
| 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 |
### Integration test (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
---
## Open Questions (to resolve during implementation)
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.
|