From d3d5a6295a67bdc99e35e5bfd77359d98a850c3f Mon Sep 17 00:00:00 2001 From: T Date: Sat, 25 Apr 2026 11:39:25 -0600 Subject: initial documentation --- docs/phase-3.md | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 docs/phase-3.md (limited to 'docs/phase-3.md') diff --git a/docs/phase-3.md b/docs/phase-3.md new file mode 100644 index 0000000..51e1341 --- /dev/null +++ b/docs/phase-3.md @@ -0,0 +1,318 @@ +# Phase 3: Extension System + +## Goal + +Introduce a Lua extension runtime and tool registration/execution, transforming `awl` 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/awl/extensions/` (or `.awl/extensions/`) and have `awl` 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/awl/extensions/mytool.lua` calling `awl.register_tool(...)` | +| Discover extensions | Place `.lua` files or directories in extension directories; `awl` loads them on startup | +| LLM calls a tool | Ask the LLM to use a registered tool; it emits a ToolUse block; awl 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: ` 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 libawl 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 + +`awl` scans two directories in order: + +1. `.awl/extensions/` — project-local extensions (relative to current working directory) +2. `~/.config/awl/extensions/` — user-level extensions + +### Naming and structure + +Extensions are identified by name, derived from their path. Two formats: + +- **Single-file**: `.lua` → extension name is `` +- **Directory**: `/init.lua` → extension name is `` + +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 — awl 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 `awl.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 awl functions into the Lua state and handles translation between Zig types and Lua types. It is compiled into the `awl` binary — it is not a separate library. + +### Functions exposed to Lua + +#### `awl.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 +awl.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 libawl as raw JSON bytes (stored in the ToolUseBlock's TextualBlock). At the Lua bridge boundary, libawl 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, libawl 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; libawl does not interpret or parse it. + +--- + +## Tool Registration (Internal) + +When `awl.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. +- awl prints: `the "" extension crashed: ` +- 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": { } + } + } + ] +} +``` + +### Anthropic + +``` +{ + "model": ..., + "system": ..., + "stream": true, + "messages": [...], + "tools": [ + { + "name": "echo", + "description": "...", + "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": "", "content": "" } +``` + +**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 `awl` 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 `awl`, 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 `awl.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. -- cgit v1.3