From 52b2ca78aed7950af27d4865aee65da781514a99 Mon Sep 17 00:00:00 2001 From: T Date: Sun, 26 Apr 2026 11:09:37 -0600 Subject: Initial work, and name change to pantograph --- .gitignore | 3 + build.zig | 52 +++++++++++ build.zig.zon | 15 ++++ docs/overview.md | 34 +++---- docs/phase-1.md | 22 ++--- docs/phase-2.md | 6 +- docs/phase-3.md | 42 ++++----- docs/phase-4.md | 64 ++++++------- ideas.md | 16 ++-- libawl/build.zig | 29 ++++++ libawl/build.zig.zon | 10 +++ libawl/src/agent.zig | 23 +++++ libawl/src/config.zig | 37 ++++++++ libawl/src/conversation.zig | 185 ++++++++++++++++++++++++++++++++++++++ libawl/src/json.zig | 4 + libawl/src/provider.zig | 66 ++++++++++++++ libawl/src/provider_openai.zig | 6 ++ libawl/src/root.zig | 9 ++ libawl/src/sse.zig | 199 +++++++++++++++++++++++++++++++++++++++++ src/main.zig | 59 ++++++++++++ 20 files changed, 789 insertions(+), 92 deletions(-) create mode 100644 .gitignore create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 libawl/build.zig create mode 100644 libawl/build.zig.zon create mode 100644 libawl/src/agent.zig create mode 100644 libawl/src/config.zig create mode 100644 libawl/src/conversation.zig create mode 100644 libawl/src/json.zig create mode 100644 libawl/src/provider.zig create mode 100644 libawl/src/provider_openai.zig create mode 100644 libawl/src/root.zig create mode 100644 libawl/src/sse.zig create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..495638d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.zig-cache/ +libawl/.zig-cache/ +zig-out/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..f7d7149 --- /dev/null +++ b/build.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const libpanto_dep = b.dependency("libpanto", .{ + .target = target, + .optimize = optimize, + }); + + // CLI executable + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + exe_mod.addImport("libpanto", libpanto_dep.module("libpanto")); + + const exe = b.addExecutable(.{ + .name = "panto", + .root_module = exe_mod, + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // CLI unit tests (minimal — most tests live in libpanto) + const test_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + test_mod.addImport("libpanto", libpanto_dep.module("libpanto")); + + const unit_tests = b.addTest(.{ + .name = "panto-tests", + .root_module = test_mod, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..71373e1 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .fingerprint = 0x922eba47080389e5, + .name = .panto, + .version = "0.0.0", + .dependencies = .{ + .libpanto = .{ + .path = "libpanto", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/docs/overview.md b/docs/overview.md index 962516d..dfc9f0f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,16 +1,16 @@ -# awl — Overview +# pantograph — Overview -`awl` is a minimal coding agent built for performance, efficiency, correctness, and a small core that can be extended deliberately. +`pantograph` is a minimal coding agent built for performance, efficiency, correctness, and a small core that can be extended deliberately. ## Ethos -**Batteries optional.** A full-featured coding agent experience ships by default — but everything can be deactivated. The standard distribution includes a curated set of coding-oriented tools and settings, all of which can be turned off. Strip out every coding tool and `awl` becomes a general-purpose LLM chat client. Additional capabilities may ship in the base but remain deactivated by default, waiting to be opted into. Nothing is mandatory; everything is intentional. +**Batteries optional.** A full-featured coding agent experience ships by default — but everything can be deactivated. The standard distribution includes a curated set of coding-oriented tools and settings, all of which can be turned off. Strip out every coding tool and `pantograph` becomes a general-purpose LLM chat client. Additional capabilities may ship in the base but remain deactivated by default, waiting to be opted into. Nothing is mandatory; everything is intentional. -**Small core, deliberate extension.** The core runtime does as little as possible. Features that would be built-ins in other agents are extensions in `awl` — including the fundamental tools like `read`, `write`, `edit`, and `bash`. The extension system is the primary mechanism for adding capability. +**Small core, deliberate extension.** The core runtime does as little as possible. Features that would be built-ins in other agents are extensions in `pantograph` — including the fundamental tools like `read`, `write`, `edit`, and `bash`. The extension system is the primary mechanism for adding capability. -**Conservative provider support.** Provider integrations are careful and complete rather than broad and broken. `awl` supports Anthropic-shaped and OpenAI-shaped APIs with arbitrary base URLs. A provider integration that partially works is worse than no integration at all. +**Conservative provider support.** Provider integrations are careful and complete rather than broad and broken. `pantograph` supports Anthropic-shaped and OpenAI-shaped APIs with arbitrary base URLs. A provider integration that partially works is worse than no integration at all. -**Own your data model.** `awl` defines its own internal conversation representation and maps to/from provider wire formats. No provider's API shape is treated as canonical. This ensures that adding a new provider never requires contorting the core model. +**Own your data model.** `pantograph` defines its own internal conversation representation and maps to/from provider wire formats. No provider's API shape is treated as canonical. This ensures that adding a new provider never requires contorting the core model. **Lean on the terminal.** The TUI does not try to be a full application framework. Scrollback, selection, and search are handled by the surrounding terminal (ghostty, tmux, etc.). The TUI's job is to present output clearly and offer targeted enhancements — like expanding or collapsing tool-call blocks — by clearing and re-rendering its own output region. This keeps the TUI simple while still providing a much nicer experience than a raw CLI. @@ -32,7 +32,7 @@ ContentBlock = Text | Thinking | ToolUse | ToolResult ### Library structure -`awl` is a library first. The core agent functionality lives in `libawl`, a Zig module. The CLI is a thin consumer of the library. A C ABI build of `libawl` will be produced when the extension system needs it (for Lua interop), implemented as thin `export fn` wrappers around the Zig API. +`pantograph` is a library first. The core agent functionality lives in `libpanto`, a Zig module. The CLI is a thin consumer of the library. A C ABI build of `libpanto` will be produced when the extension system needs it (for Lua interop), implemented as thin `export fn` wrappers around the Zig API. ### Provider abstraction @@ -40,26 +40,26 @@ Providers implement a streaming interface: given a conversation, stream a respon ### Extension system -Extensions will initially be written in Lua, requiring a C ABI surface on `libawl`. Future support for shared-object extensions (Zig, Rust, C, C++) will use the same C ABI. Core tools like `read`, `write`, `edit`, and `bash` are extensions — individually disableable, included in the standard distribution but not hardcoded into the runtime. +Extensions will initially be written in Lua, requiring a C ABI surface on `libpanto`. Future support for shared-object extensions (Zig, Rust, C, C++) will use the same C ABI. Core tools like `read`, `write`, `edit`, and `bash` are extensions — individually disableable, included in the standard distribution but not hardcoded into the runtime. ### Server/proxy mode -In a future phase, `awl` will be able to run as a server exposing OpenAI-compatible and Anthropic-compatible APIs, acting as a lightweight provider router/proxy to its configured backends. This is not yet planned in detail. +In a future phase, `pantograph` will be able to run as a server exposing OpenAI-compatible and Anthropic-compatible APIs, acting as a lightweight provider router/proxy to its configured backends. This is not yet planned in detail. ## Phase Roadmap | Phase | Deliverable | Doc | |-------|-------------|-----| -| 1 | libawl — minimal chat library, OpenAI provider, streaming, minimal CLI | [phase-1.md](phase-1.md) | +| 1 | libpanto — minimal chat library, OpenAI provider, streaming, minimal CLI | [phase-1.md](phase-1.md) | | 2 | Anthropic provider — second provider, validates the abstraction | phase-2.md | | 3 | Extension API — Lua runtime, extension loading, tool registration | phase-3.md | | 4 | Conversation serialization — JSONL event log, session save/resume, crash recovery | [phase-4.md](phase-4.md) | | 5 | Core tools — read/write/edit/bash as distributable extensions | phase-5.md | | 6 | Rounded coding agent — slash commands, TOML config, extended TUI | phase-6.md | -### Phase 1: libawl +### Phase 1: libpanto -A Zig library that holds a streaming conversation with an LLM via an OpenAI-compatible API. No tools, no extensions — just chat. Ships a minimal CLI (`awl` binary) for live testing: readline, send, print streamed response, repeat. The conversation model is established with all four ContentBlock variants defined (ToolUse and ToolResult exist in the type but are never produced in this phase). +A Zig library that holds a streaming conversation with an LLM via an OpenAI-compatible API. No tools, no extensions — just chat. Ships a minimal CLI (`panto` binary) for live testing: readline, send, print streamed response, repeat. The conversation model is established with all four ContentBlock variants defined (ToolUse and ToolResult exist in the type but are never produced in this phase). ### Phase 2: Anthropic Provider @@ -67,7 +67,7 @@ A second provider implementation targeting Anthropic's API shape. Validates that ### Phase 3: Extension API -Introduces a Lua extension runtime and the extension loading mechanism. Extensions can register tools, access configuration, and participate in the agent loop. This phase also produces the C ABI build of `libawl` needed for Lua interop. Tools exist but none ship yet — the extension system is the deliverable, not the tools. +Introduces a Lua extension runtime and the extension loading mechanism. Extensions can register tools, access configuration, and participate in the agent loop. This phase also produces the C ABI build of `libpanto` needed for Lua interop. Tools exist but none ship yet — the extension system is the deliverable, not the tools. ### Phase 4: Conversation Serialization @@ -75,11 +75,11 @@ Save and resume conversations. Sessions are stored as append-only JSONL event lo ### Phase 5: Core Tools as Extensions -The fundamental coding tools — `read`, `write`, `edit`, `bash` — are implemented as extensions (initially Lua, eventually native). They live under the `std` namespace: `std.read`, `std.write`, `std.edit`, `std.bash`. The `std` package is a curated set of coding-oriented extensions — some enabled by default, some available but deactivated — embodying the "batteries optional" ethos. They ship with the standard distribution but are individually disableable. This is where `awl` becomes a functional coding agent rather than just a chat client. +The fundamental coding tools — `read`, `write`, `edit`, `bash` — are implemented as extensions (initially Lua, eventually native). They live under the `std` namespace: `std.read`, `std.write`, `std.edit`, `std.bash`. The `std` package is a curated set of coding-oriented extensions — some enabled by default, some available but deactivated — embodying the "batteries optional" ethos. They ship with the standard distribution but are individually disableable. This is where `pantograph` becomes a functional coding agent rather than just a chat client. ### Phase 6: Rounded Coding Agent -Polish and capstone features that make `awl` a well-rounded coding agent experience: +Polish and capstone features that make `pantograph` a well-rounded coding agent experience: - **Slash commands** — an extensible framework for `/`-prefixed commands (e.g., `/help`, `/model`, `/clear`) - **TOML configuration** — a config file for persistent settings (default model, enabled/disabled extensions, provider configs, system prompt templates) @@ -90,8 +90,8 @@ Polish and capstone features that make `awl` a well-rounded coding agent experie These are recorded from the initial ideas but do not yet have phase documents or detailed plans: -- **Server/proxy mode** — run `awl` as a server exposing OpenAI-compatible and Anthropic-compatible APIs, routing to configured backends +- **Server/proxy mode** — run `pantograph` as a server exposing OpenAI-compatible and Anthropic-compatible APIs, routing to configured backends - **Shared-object extensions** — extend the extension system beyond Lua to support native shared libraries via the C ABI (Zig, Rust, C, C++) - **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 libawl** — `export fn` wrappers exposing libawl functionality through a C calling convention, enabling external programs to embed or build on awl from C, Rust, or other native languages. Not a separate library — the C ABI is a second interface on the same `libawl` 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. +- **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. diff --git a/docs/phase-1.md b/docs/phase-1.md index d9a8450..8acaf42 100644 --- a/docs/phase-1.md +++ b/docs/phase-1.md @@ -1,4 +1,4 @@ -# Phase 1: libawl — Minimal Chat Library +# Phase 1: libpanto — Minimal Chat Library ## Goal @@ -6,9 +6,9 @@ A Zig library that can hold a streaming conversation with an LLM via an OpenAI-c ## Deliverable -A `libawl` Zig module importable by other Zig code, plus a `awl` binary that wires it into a basic read/print loop. At the end of this phase, you can: +A `libpanto` Zig module importable by other Zig code, plus a `panto` binary that wires it into a basic read/print loop. At the end of this phase, you can: -- Start `awl`, type a message, and receive a streamed response from an OpenAI-compatible LLM. +- Start `panto`, type a message, and receive a streamed response from an OpenAI-compatible LLM. - Send follow-up messages that include full conversation history. - See thinking tokens and text tokens stream in as they arrive. - Have the complete conversation available in memory for the duration of the session. @@ -17,7 +17,7 @@ A `libawl` Zig module importable by other Zig code, plus a `awl` binary that wir | Capability | How to exercise it | |---|---| -| Open a conversation | `awl.conversation.Conversation.init(allocator)` | +| Open a conversation | `libpanto.conversation.Conversation.init(allocator)` | | Add a user message | `conversation.addUserMessage("hello")` | | Run an agent step (streaming) | `agent.runStep(conversation, &receiver)` | | See streamed output | CLI prints thinking/text chunks as they arrive | @@ -91,7 +91,7 @@ The `input` field of `ToolUse` is stored as raw JSON bytes (`[]const u8`) rather `ToolUse` blocks also stream incrementally — both providers send tool input as JSON fragments across multiple deltas. Therefore `ToolUse.input` also uses `TextualBlock` for assembly. `id` and `name` arrive at block-start time and are stored as owned `[]const u8` copies. -`ToolResult` blocks are constructed by `awl` itself (not streamed from a provider), so `content` could be a simple `[]const u8`. However, for consistency and to allow progressive construction of results, it also uses `TextualBlock`. +`ToolResult` blocks are constructed by `pantograph` itself (not streamed from a provider), so `content` could be a simple `[]const u8`. However, for consistency and to allow progressive construction of results, it also uses `TextualBlock`. Updated types: ``` @@ -216,7 +216,7 @@ BlockMeta = struct { - Callbacks are always invoked in this order for every block, regardless of which provider is active. - `onMessageStart` is called when the stream begins delivering a new message. - `onBlockStart` is called when a new content block begins. `meta` carries block-type-specific metadata (tool id/name for ToolUse, null for Text/Thinking). -- `onContentDelta` is called zero or more times per block with raw byte fragments. For Text/Thinking these are word fragments; for ToolUse these are JSON fragments. The receiver does not need to interpret them. `delta` is a `[]const u8` — libawl does not parse tool input content, it passes bytes through. +- `onContentDelta` is called zero or more times per block with raw byte fragments. For Text/Thinking these are word fragments; for ToolUse these are JSON fragments. The receiver does not need to interpret them. `delta` is a `[]const u8` — libpanto does not parse tool input content, it passes bytes through. - `onBlockComplete` is called when a block is finished. The `block` parameter contains the fully assembled ContentBlock. The receiver that only needs complete content can ignore deltas and use this. - `onMessageComplete` is called when the stream ends. The `message` parameter contains the fully assembled Message with all blocks. - Providers guarantee that `onBlockComplete`'s `block` and `onMessageComplete`'s `message` are always fully assembled and valid. @@ -334,7 +334,7 @@ Config = struct { }; ``` -Populated from environment variables (`AWL_API_KEY`, `AWL_BASE_URL`, `AWL_MODEL`) with defaults for base_url and model. +Populated from environment variables (`PANTO_API_KEY`, `PANTO_BASE_URL`, `PANTO_MODEL`) with defaults for base_url and model. ### `root.zig` @@ -403,19 +403,19 @@ The `finish_reason: "stop"` signals stream end → emit onBlockComplete for any ## Minimal CLI ``` -awl/ +panto/ src/ main.zig // CLI entry point ``` Behavior: -1. Read `AWL_API_KEY`, `AWL_BASE_URL`, `AWL_MODEL` from environment +1. Read `PANTO_API_KEY`, `PANTO_BASE_URL`, `PANTO_MODEL` from environment 2. Create a Conversation, add a system message (default: "You are a helpful assistant.") 3. Print a prompt (`> `), read a line from stdin 4. Add user message, call `agent.runStep()`, print streamed deltas to stdout 5. Repeat step 3 until EOF (Ctrl+D) -There is no line editing, no scrolling, no syntax highlighting. Just `readline` → `print`. The sole purpose is exercising libawl against a real API. +There is no line editing, no scrolling, no syntax highlighting. Just `readline` → `print`. The sole purpose is exercising libpanto against a real API. --- @@ -432,7 +432,7 @@ There is no line editing, no scrolling, no syntax highlighting. Just `readline` ### Integration test (manual) -- Run `awl` binary with a real API key +- Run `panto` binary with a real API key - Hold a multi-turn conversation - Verify responses stream to stdout - Verify follow-up messages include prior context (ask the model "what did I just say?") diff --git a/docs/phase-2.md b/docs/phase-2.md index 877f8bd..af45546 100644 --- a/docs/phase-2.md +++ b/docs/phase-2.md @@ -84,7 +84,7 @@ No inference needed — Anthropic gives us explicit boundaries. - Thinking → `{ "type": "thinking", "thinking": "..." }` - ToolUse → `{ "type": "tool_use", "id": "...", "name": "...", "input": {...} }` (phase 3+) -Note: Anthropic expects ToolUse's `input` as a parsed JSON object, not a string. Since we store `input` as raw bytes in a TextualBlock, we will need to parse it into a `std.json.Value` during Anthropic serialization. This is the one place where libawl does parse tool input JSON — it's a serialization requirement, not an interpretation of the tool schema. The round-trip guarantee is: the bytes we stored serialize back to equivalent JSON when sent to Anthropic. +Note: Anthropic expects ToolUse's `input` as a parsed JSON object, not a string. Since we store `input` as raw bytes in a TextualBlock, we will need to parse it into a `std.json.Value` during Anthropic serialization. This is the one place where libpanto does parse tool input JSON — it's a serialization requirement, not an interpretation of the tool schema. The round-trip guarantee is: the bytes we stored serialize back to equivalent JSON when sent to Anthropic. --- @@ -132,7 +132,7 @@ AnthropicConfig = struct { }; ``` -Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them; libawl treats them as distinct. +Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them; libpanto treats them as distinct. --- @@ -149,7 +149,7 @@ Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them ### Integration test (manual) -- Run `awl` binary against Anthropic API with a real API key +- Run `panto` binary against Anthropic API with a real API key - Hold a multi-turn conversation with thinking model - Verify thinking and text stream correctly - Switch to OpenAI provider, verify same experience diff --git a/docs/phase-3.md b/docs/phase-3.md index 51e1341..1b9d83a 100644 --- a/docs/phase-3.md +++ b/docs/phase-3.md @@ -2,14 +2,14 @@ ## 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. +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/awl/extensions/` (or `.awl/extensions/`) and have `awl` discover and load it. +- 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. @@ -18,9 +18,9 @@ A working extension system where Lua scripts can register tools and handle tool- | 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 | +| 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: ` and aborts the turn | @@ -29,7 +29,7 @@ A working extension system where Lua scripts can register tools and handle tool- - 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) +- 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) @@ -41,10 +41,10 @@ A working extension system where Lua scripts can register tools and handle tool- ### Directory locations -`awl` scans two directories in order: +`pantograph` scans two directories in order: -1. `.awl/extensions/` — project-local extensions (relative to current working directory) -2. `~/.config/awl/extensions/` — user-level extensions +1. `.panto/extensions/` — project-local extensions (relative to current working directory) +2. `~/.config/panto/extensions/` — user-level extensions ### Naming and structure @@ -58,25 +58,25 @@ 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). +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 `awl.register_tool(...)` to register its tools. +- 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 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. +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 -#### `awl.register_tool(name, schema, handler)` +#### `panto.register_tool(name, schema, handler)` Registers a tool with the agent. @@ -87,7 +87,7 @@ Registers a tool with the agent. Example: ```lua -awl.register_tool("echo", { +panto.register_tool("echo", { type = "object", properties = { message = { type = "string", description = "The message to echo back" } @@ -100,17 +100,17 @@ 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. +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; libawl does not interpret or parse it. +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 `awl.register_tool()` is called from Lua, the bridge stores: +When `panto.register_tool()` is called from Lua, the bridge stores: ``` RegisteredTool = struct { @@ -185,7 +185,7 @@ end, input_table) If the handler crashes: - The error and stack trace are captured as a string. -- awl prints: `the "" extension crashed: ` +- pantograph 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). @@ -283,7 +283,7 @@ src/extension_loader.zig // Directory scanning, extension discovery and loading ### 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. +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. --- @@ -302,7 +302,7 @@ Lua interpreter linked into the `awl` binary. Zig's build system can fetch and c ### Integration test (manual) - Write a simple `echo.lua` extension, place it in extension directory -- Start `awl`, ask the LLM to use the echo tool +- 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 @@ -315,4 +315,4 @@ Lua interpreter linked into the `awl` binary. Zig's build system can fetch and c 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. +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. diff --git a/docs/phase-4.md b/docs/phase-4.md index 268467a..af68cca 100644 --- a/docs/phase-4.md +++ b/docs/phase-4.md @@ -2,27 +2,27 @@ ## Goal -Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, awl replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later. +Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, pantograph replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later. ## Deliverable -A session persistence system where awl saves and resumes conversations. At the end of this phase, you can: +A session persistence system where pantograph saves and resumes conversations. At the end of this phase, you can: -- Start `awl`, hold a conversation, and find the session saved to disk. -- Restart `awl --resume` and continue the conversation exactly where you left off. -- Restart `awl --resume ` to resume a specific session. +- Start `panto`, hold a conversation, and find the session saved to disk. +- Restart `panto --resume` and continue the conversation exactly where you left off. +- Restart `panto --resume ` to resume a specific session. - Inspect the JSONL session file to see every event that occurred, including which provider/model handled each turn. -- Have awl recover gracefully from a crash — the corrupted trailing line is removed, and the session loads from the last valid entry. +- Have pantograph recover gracefully from a crash — the corrupted trailing line is removed, and the session loads from the last valid entry. ## What is usable at the end | Capability | How to exercise it | |---|---| -| Auto-save session | Start `awl`, converse, quit; session is in `~/.local/share/awl/sessions/` | -| Resume most recent | `awl --resume` — continues the most recent session for the current working directory | -| Resume specific session | `awl --resume ` — opens the session with the given ID (or unique prefix) | -| List sessions | `awl sessions` — lists sessions for the current working directory | -| Crash recovery | Kill `awl` mid-turn; restart with `--resume`; corrupted trailing line is removed, session loads from last valid entry | +| Auto-save session | Start `panto`, converse, quit; session is in `~/.local/share/panto/sessions/` | +| Resume most recent | `panto --resume` — continues the most recent session for the current working directory | +| Resume specific session | `panto --resume ` — opens the session with the given ID (or unique prefix) | +| List sessions | `panto sessions` — lists sessions for the current working directory | +| Crash recovery | Kill `panto` mid-turn; restart with `--resume`; corrupted trailing line is removed, session loads from last valid entry | ## What is explicitly out of scope @@ -40,12 +40,12 @@ A session persistence system where awl saves and resumes conversations. At the e Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Entries form a tree structure via `id`/`parentId` fields, enabling in-place branching in future phases without creating new files. -The event log is the authoritative record of a session. It captures everything that happened — not just the conversation content, but the full context of how it happened (which model was active, when it changed, etc.). On load, awl rebuilds all state from the log alone. +The event log is the authoritative record of a session. It captures everything that happened — not just the conversation content, but the full context of how it happened (which model was active, when it changed, etc.). On load, pantograph rebuilds all state from the log alone. ### File Location ``` -~/.local/share/awl/sessions//_.jsonl +~/.local/share/panto/sessions//_.jsonl ``` Where `` is the working directory with `/` replaced by `--`. This groups sessions by project directory, making it easy to find sessions for a given project. @@ -54,7 +54,7 @@ The base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if Example: ``` -~/.local/share/awl/sessions/--Users-travis-Code-awl--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl +~/.local/share/panto/sessions/--Users-travis-Code-pantograph--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl ``` ### Crash Recovery @@ -75,7 +75,7 @@ First line of the file. Metadata only — not part of the tree (no `id`/`parentI "version": 1, "id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572", "timestamp": "2026-04-25T17:40:15.990Z", - "cwd": "/Users/travis/Code/awl", + "cwd": "/Users/travis/Code/pantograph", "provider": "anthropic", "model": "claude-sonnet-4-20250514" } @@ -181,11 +181,11 @@ A message in the conversation. The `message` field contains role, content blocks } ``` -Note that tool results are content blocks on a user message, consistent with awl's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in awl, tool results are content blocks that happen to live on user messages, just as they do in the Conversation model. +Note that tool results are content blocks on a user message, consistent with pantograph's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in pantograph, tool results are content blocks that happen to live on user messages, just as they do in the Conversation model. ### Content Block Serialization -The on-disk content block types correspond directly to awl's internal `ContentBlock` tagged union: +The on-disk content block types correspond directly to pantograph's internal `ContentBlock` tagged union: | Internal type | On-disk `type` | Fields | |---|---|---| @@ -196,8 +196,8 @@ The on-disk content block types correspond directly to awl's internal `ContentBl Notable decisions: -- **`input` is a string, not a parsed object.** Consistent with awl's internal model where tool input is stored as raw JSON bytes. The on-disk format does not parse or interpret tool schemas. -- **`content` in `toolResult` is a string.** Consistent with awl's model where tool result content is a single `TextualBlock`. If structured content (e.g., images) is needed later, the format can evolve. +- **`input` is a string, not a parsed object.** Consistent with pantograph's internal model where tool input is stored as raw JSON bytes. The on-disk format does not parse or interpret tool schemas. +- **`content` in `toolResult` is a string.** Consistent with pantograph's model where tool result content is a single `TextualBlock`. If structured content (e.g., images) is needed later, the format can evolve. - **`thinking` stores full thinking content.** The event log records everything that happened. Thinking blocks are never omitted. ### Assistant message metadata @@ -279,7 +279,7 @@ The tree structure is defined now so that the on-disk format supports branching ## Rebuilding State from the Log -On resume, awl fully rebuilds conversation state by replaying the event log: +On resume, pantograph fully rebuilds conversation state by replaying the event log: 1. **Read the file line by line.** Parse each line as a JSON object. 2. **Crash recovery.** If the last line doesn't parse as a complete JSON object, delete it from the file. @@ -299,7 +299,7 @@ The Conversation stores only `role` and `content` per message — the data neede ### Handling incomplete turns on resume -If the session was interrupted mid-turn, the log may contain an assistant message with `ToolUse` blocks but no corresponding user message with `ToolResult` blocks. On resume, awl does **not** automatically re-execute the tools. The conversation is rebuilt as-is and presented to the user. The agent loop waits for user input — the user decides how to proceed. +If the session was interrupted mid-turn, the log may contain an assistant message with `ToolUse` blocks but no corresponding user message with `ToolResult` blocks. On resume, pantograph does **not** automatically re-execute the tools. The conversation is rebuilt as-is and presented to the user. The agent loop waits for user input — the user decides how to proceed. --- @@ -313,7 +313,7 @@ If the session was interrupted mid-turn, the log may contain an assistant messag A single agent turn with tool calls appends multiple rounds of these entries. -**File creation.** The session file is created when the session is initialized (header written). If `awl` starts and the user immediately quits without the session being initialized, no file is created. +**File creation.** The session file is created when the session is initialized (header written). If `pantograph` starts and the user immediately quits without the session being initialized, no file is created. **No explicit save command.** Persistence is automatic. Every event is written to disk as it happens. @@ -510,7 +510,7 @@ SessionInfo = struct { ### Modified files - **`agent.zig`** — Receives a `SessionManager` reference. After each message is assembled, calls `appendMessage()` with the current provider/model (user messages get stamped; assistant/system messages pass null). The agent loop is now a producer of session entries. -- **`config.zig`** — Session directory path added. Default: `$XDG_DATA_HOME/awl/sessions/` (falling back to `~/.local/share/awl/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `AWL_SESSION_DIR` environment variable. +- **`config.zig`** — Session directory path added. Default: `$XDG_DATA_HOME/panto/sessions/` (falling back to `~/.local/share/panto/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `PANTO_SESSION_DIR` environment variable. - **`main.zig`** — `--resume` and `--resume ` flags. `sessions` subcommand. On startup with `--resume`, load session, rebuild conversation, continue agent loop. Without `--resume`, create a new session. --- @@ -522,7 +522,7 @@ SessionInfo = struct { Resume the most recent session for the current working directory. If no sessions exist, create a new one. ``` -awl --resume +panto --resume ``` ### `--resume ` @@ -530,7 +530,7 @@ awl --resume Resume a specific session by its ID (or a unique prefix of the ID). Errors if no session matches, or if the prefix is ambiguous. ``` -awl --resume 019dc5ba +panto --resume 019dc5ba ``` ### `sessions` subcommand @@ -538,7 +538,7 @@ awl --resume 019dc5ba List sessions for the current working directory. Shows session ID (short), creation time, and message count. ``` -awl sessions +panto sessions ``` Output: @@ -564,12 +564,12 @@ Output: ### Integration test (manual) -- Start `awl`, hold a multi-turn conversation with tool calls, quit +- Start `panto`, hold a multi-turn conversation with tool calls, quit - Inspect the JSONL file — verify entries are present and well-formed -- Run `awl --resume` — verify conversation continues from where it left off +- Run `panto --resume` — verify conversation continues from where it left off - Change the model mid-session, send a prompt, verify the user message entry in the log carries the updated `provider`/`model` fields -- Kill `awl` mid-turn (e.g., Ctrl+C during streaming), run `awl --resume` — verify session loads from last valid entry -- Run `awl sessions` — verify sessions are listed with correct metadata +- Kill `pantograph` mid-turn (e.g., Ctrl+C during streaming), run `panto --resume` — verify session loads from last valid entry +- Run `panto sessions` — verify sessions are listed with correct metadata --- @@ -577,6 +577,6 @@ Output: 1. **Entry ID generation**: 8-char hex IDs give ~4 billion possibilities. Collision probability within a single session is negligible, but should we verify uniqueness against existing entries, or trust the randomness? 2. **Timestamp precision**: ISO 8601 with millisecond precision is sufficient. Zig's `std.time` provides nanosecond precision — we format to milliseconds. -3. **File locking**: If two `awl` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4? +3. **File locking**: If two `pantograph` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4? 4. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt Conversation state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? Or defer this optimization? -5. **Session directory creation**: Should `awl` create the session directory tree eagerly on startup, or lazily when the first session is created? +5. **Session directory creation**: Should `pantograph` create the session directory tree eagerly on startup, or lazily when the first session is created? diff --git a/ideas.md b/ideas.md index 731e994..e83d543 100644 --- a/ideas.md +++ b/ideas.md @@ -1,22 +1,22 @@ -# awl Ideas +# pantograph Ideas -`awl` is a minimal coding agent inspired by `pi`, with a focus on performance, efficiency, correctness, and a small core that can be extended deliberately. +`pantograph` is a minimal coding agent inspired by `pi`, with a focus on performance, efficiency, correctness, and a small core that can be extended deliberately. ## Core architecture - The core agent loop will be written in Zig for performance, efficiency, and a small runtime footprint. -- In addition to the CLI/application, the project should expose a `libawl` library through a C ABI so other programs can embed or build on the agent functionality. +- In addition to the CLI/application, the project should expose a `libpanto` library through a C ABI so other programs can embed or build on the agent functionality. ## Extension system - Extensions will initially be written in Lua, chosen for speed, simplicity, and low overhead. -- Because poorly written extensions can easily destabilize the host, `awl` should explore ways to improve extension correctness and crash protection. +- Because poorly written extensions can easily destabilize the host, `pantograph` should explore ways to improve extension correctness and crash protection. - Future extension support should include loading shared object libraries through a C ABI, allowing extensions to be written in Zig, Rust, C, C++, or other native languages. ## Minimal built-in feature set -- `awl` should follow `pi`'s philosophy of avoiding unnecessary built-ins such as native subagents, MCP support, and permission systems. -- `awl` will go further by not building in AGENTS.md automation, skills, or customizable `/prompts`. +- `pantograph` should follow `pi`'s philosophy of avoiding unnecessary built-ins such as native subagents, MCP support, and permission systems. +- `pantograph` will go further by not building in AGENTS.md automation, skills, or customizable `/prompts`. - Those features should be possible to implement as extensions rather than being part of the core runtime. ## Provider API support @@ -27,8 +27,8 @@ ## Server/proxy mode -- `awl` should support running as a server that exposes OpenAI-compatible and Anthropic-compatible APIs. -- In this mode, `awl` can act as a lightweight provider router/proxy to its configured backends. +- `pantograph` should support running as a server that exposes OpenAI-compatible and Anthropic-compatible APIs. +- In this mode, `pantograph` can act as a lightweight provider router/proxy to its configured backends. - This is intended to provide the useful parts of tools like `omniroute` while avoiding excessive memory usage, fragile integrations, and runtime instability. ## Core tools as extensions diff --git a/libawl/build.zig b/libawl/build.zig new file mode 100644 index 0000000..5e8442b --- /dev/null +++ b/libawl/build.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Public module — other packages can import this via dependency + _ = b.addModule("libpanto", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + + // Unit tests + const test_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + + const unit_tests = b.addTest(.{ + .name = "libpanto-tests", + .root_module = test_module, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/libawl/build.zig.zon b/libawl/build.zig.zon new file mode 100644 index 0000000..f00be75 --- /dev/null +++ b/libawl/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .fingerprint = 0xa69627633d440178, + .name = .libpanto, + .version = "0.0.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libawl/src/agent.zig b/libawl/src/agent.zig new file mode 100644 index 0000000..7ebc058 --- /dev/null +++ b/libawl/src/agent.zig @@ -0,0 +1,23 @@ +const std = @import("std"); +const provider = @import("provider.zig"); +const conversation = @import("conversation.zig"); + +pub const Agent = struct { + provider: provider.Provider, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, prov: provider.Provider) Agent { + return .{ + .provider = prov, + .allocator = allocator, + }; + } + + pub fn runStep(self: Agent, conv: *conversation.Conversation, receiver: *provider.Receiver) !void { + try self.provider.streamStep(conv, receiver); + } + + pub fn deinit(self: Agent) void { + self.provider.deinit(); + } +}; diff --git a/libawl/src/config.zig b/libawl/src/config.zig new file mode 100644 index 0000000..8b6f147 --- /dev/null +++ b/libawl/src/config.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +pub const Config = struct { + api_key: []const u8, + base_url: []const u8, + model: []const u8, + + pub const Error = error{MissingApiKey}; + + pub fn fromEnv() Error!Config { + const api_key_z = std.c.getenv("PANTO_API_KEY") orelse return Error.MissingApiKey; + const api_key = std.mem.sliceTo(api_key_z, 0); + + const base_url_z = std.c.getenv("PANTO_BASE_URL") orelse "https://api.openai.com/v1"; + const base_url = std.mem.sliceTo(base_url_z, 0); + + const model_z = std.c.getenv("PANTO_MODEL") orelse "gpt-4o"; + const model = std.mem.sliceTo(model_z, 0); + + return Config{ + .api_key = api_key, + .base_url = base_url, + .model = model, + }; + } +}; + +test "Config - defaults" { + const cfg = Config{ + .api_key = "test-key", + .base_url = "https://api.openai.com/v1", + .model = "gpt-4o", + }; + try std.testing.expectEqualStrings("test-key", cfg.api_key); + try std.testing.expectEqualStrings("https://api.openai.com/v1", cfg.base_url); + try std.testing.expectEqualStrings("gpt-4o", cfg.model); +} diff --git a/libawl/src/conversation.zig b/libawl/src/conversation.zig new file mode 100644 index 0000000..0e9a4e9 --- /dev/null +++ b/libawl/src/conversation.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A streaming text buffer used by content blocks. +/// Thin alias over ArrayList(u8) — amortized O(1) appends, +/// no O(n²) re-copying. +pub const TextualBlock = std.ArrayList(u8); + +/// Create a TextualBlock with initial content (copies the slice). +pub fn textualBlockFromSlice(alloc: Allocator, slice: []const u8) !TextualBlock { + var buf: TextualBlock = .empty; + try buf.appendSlice(alloc, slice); + return buf; +} + +pub const ToolUseBlock = struct { + id: []const u8, + name: []const u8, + input: TextualBlock = .empty, + + pub fn deinit(self: *ToolUseBlock, alloc: Allocator) void { + alloc.free(self.id); + alloc.free(self.name); + self.input.deinit(alloc); + } +}; + +pub const ToolResultBlock = struct { + tool_use_id: []const u8, + content: TextualBlock = .empty, + + pub fn deinit(self: *ToolResultBlock, alloc: Allocator) void { + alloc.free(self.tool_use_id); + self.content.deinit(alloc); + } +}; + +pub const ContentBlock = union(enum) { + Text: TextualBlock, + Thinking: TextualBlock, + ToolUse: ToolUseBlock, + ToolResult: ToolResultBlock, + + pub fn deinit(self: *ContentBlock, alloc: Allocator) void { + switch (self.*) { + inline else => |*b| b.deinit(alloc), + } + } +}; + +pub const MessageRole = enum { + system, + user, + assistant, +}; + +pub const Message = struct { + role: MessageRole, + content: std.ArrayList(ContentBlock) = .empty, + + pub fn deinit(self: *Message, alloc: Allocator) void { + for (self.content.items) |*block| { + block.deinit(alloc); + } + self.content.deinit(alloc); + } +}; + +pub const Conversation = struct { + messages: std.ArrayList(Message) = .empty, + allocator: Allocator, + + pub fn init(allocator: Allocator) Conversation { + return .{ + .allocator = allocator, + }; + } + + pub fn addSystemMessage(self: *Conversation, text: []const u8) !void { + const tb = try textualBlockFromSlice(self.allocator, text); + var content: std.ArrayList(ContentBlock) = .empty; + try content.append(self.allocator, .{ .Text = tb }); + try self.messages.append(self.allocator, .{ + .role = .system, + .content = content, + }); + } + + pub fn addUserMessage(self: *Conversation, text: []const u8) !void { + const tb = try textualBlockFromSlice(self.allocator, text); + var content: std.ArrayList(ContentBlock) = .empty; + try content.append(self.allocator, .{ .Text = tb }); + try self.messages.append(self.allocator, .{ + .role = .user, + .content = content, + }); + } + + /// Append an assistant message. Ownership of the blocks is transferred + /// to the conversation; the caller must not deinit them after this call. + pub fn addAssistantMessage(self: *Conversation, blocks: []const ContentBlock) !void { + var content: std.ArrayList(ContentBlock) = .empty; + try content.ensureTotalCapacity(self.allocator, blocks.len); + for (blocks) |block| { + content.appendAssumeCapacity(block); + } + try self.messages.append(self.allocator, .{ + .role = .assistant, + .content = content, + }); + } + + pub fn deinit(self: *Conversation) void { + for (self.messages.items) |*msg| { + msg.deinit(self.allocator); + } + self.messages.deinit(self.allocator); + } +}; + +test "Conversation - add messages and verify content" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("You are a helpful assistant."); + try conv.addUserMessage("Hello!"); + try conv.addAssistantMessage(&.{ + .{ .Text = try textualBlockFromSlice(allocator, "Hi there!") }, + }); + + try std.testing.expectEqual(@as(usize, 3), conv.messages.items.len); + + try std.testing.expectEqual(MessageRole.system, conv.messages.items[0].role); + try std.testing.expectEqualStrings( + "You are a helpful assistant.", + conv.messages.items[0].content.items[0].Text.items, + ); + + try std.testing.expectEqual(MessageRole.user, conv.messages.items[1].role); + try std.testing.expectEqualStrings("Hello!", conv.messages.items[1].content.items[0].Text.items); + + try std.testing.expectEqual(MessageRole.assistant, conv.messages.items[2].role); + try std.testing.expectEqualStrings("Hi there!", conv.messages.items[2].content.items[0].Text.items); +} + +test "TextualBlock - incremental append" { + const allocator = std.testing.allocator; + + var tb = TextualBlock.empty; + defer tb.deinit(allocator); + + try tb.appendSlice(allocator, "Hello"); + try tb.appendSlice(allocator, " world"); + try std.testing.expectEqualStrings("Hello world", tb.items); +} + +test "Conversation - deinit frees without leaks" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + try conv.addSystemMessage("system"); + try conv.addUserMessage("user message"); + try conv.addAssistantMessage(&.{ + .{ .Text = try textualBlockFromSlice(allocator, "response") }, + }); + conv.deinit(); +} + +test "ContentBlock - Thinking variant" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addAssistantMessage(&.{ + .{ .Thinking = try textualBlockFromSlice(allocator, "hmm...") }, + .{ .Text = try textualBlockFromSlice(allocator, "answer") }, + }); + + try std.testing.expectEqual(@as(usize, 2), conv.messages.items[0].content.items.len); + try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.items); + try std.testing.expectEqualStrings("answer", conv.messages.items[0].content.items[1].Text.items); +} diff --git a/libawl/src/json.zig b/libawl/src/json.zig new file mode 100644 index 0000000..542391e --- /dev/null +++ b/libawl/src/json.zig @@ -0,0 +1,4 @@ +// Serialization helpers — model → wire JSON, deltas → ContentBlocks. +// Implementation pending; this module will handle: +// 1. Serialize Conversation → OpenAI request body JSON +// 2. Parse SSE chunk deltas → ContentBlock updates diff --git a/libawl/src/provider.zig b/libawl/src/provider.zig new file mode 100644 index 0000000..8d6eea5 --- /dev/null +++ b/libawl/src/provider.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const conversation = @import("conversation.zig"); + +pub const ContentBlockType = enum { + Text, + Thinking, + ToolUse, + ToolResult, +}; + +pub const BlockMeta = struct { + /// Only populated for ToolUse blocks. Null for Text/Thinking. + tool_id: ?[]const u8 = null, + tool_name: ?[]const u8 = null, +}; + +pub const ReceiverVTable = struct { + onMessageStart: *const fn (*anyopaque, conversation.MessageRole) void, + onBlockStart: *const fn (*anyopaque, ContentBlockType, usize, ?BlockMeta) void, + onContentDelta: *const fn (*anyopaque, usize, []const u8) void, + onBlockComplete: *const fn (*anyopaque, usize, conversation.ContentBlock) void, + onMessageComplete: *const fn (*anyopaque, conversation.Message) void, +}; + +pub const Receiver = struct { + ptr: *anyopaque, + vtable: *const ReceiverVTable, + + pub fn onMessageStart(self: Receiver, role: conversation.MessageRole) void { + self.vtable.onMessageStart(self.ptr, role); + } + + pub fn onBlockStart(self: Receiver, block_type: ContentBlockType, index: usize, meta: ?BlockMeta) void { + self.vtable.onBlockStart(self.ptr, block_type, index, meta); + } + + pub fn onContentDelta(self: Receiver, block_index: usize, delta: []const u8) void { + self.vtable.onContentDelta(self.ptr, block_index, delta); + } + + pub fn onBlockComplete(self: Receiver, block_index: usize, block: conversation.ContentBlock) void { + self.vtable.onBlockComplete(self.ptr, block_index, block); + } + + pub fn onMessageComplete(self: Receiver, message: conversation.Message) void { + self.vtable.onMessageComplete(self.ptr, message); + } +}; + +pub const ProviderVTable = struct { + streamStep: *const fn (*anyopaque, *conversation.Conversation, *Receiver) anyerror!void, + deinit: *const fn (*anyopaque) void, +}; + +pub const Provider = struct { + ptr: *anyopaque, + vtable: *const ProviderVTable, + + pub fn streamStep(self: Provider, conv: *conversation.Conversation, receiver: *Receiver) anyerror!void { + return self.vtable.streamStep(self.ptr, conv, receiver); + } + + pub fn deinit(self: Provider) void { + self.vtable.deinit(self.ptr); + } +}; diff --git a/libawl/src/provider_openai.zig b/libawl/src/provider_openai.zig new file mode 100644 index 0000000..c459a9e --- /dev/null +++ b/libawl/src/provider_openai.zig @@ -0,0 +1,6 @@ +// OpenAI-compatible provider implementation. +// Implementation pending; this module will: +// - Convert Conversation → OpenAI wire JSON +// - Make HTTP POST with stream: true +// - Parse SSE events, drive block boundary state machine +// - Call the full Receiver callback sequence diff --git a/libawl/src/root.zig b/libawl/src/root.zig new file mode 100644 index 0000000..72ceb97 --- /dev/null +++ b/libawl/src/root.zig @@ -0,0 +1,9 @@ +pub const conversation = @import("conversation.zig"); +pub const provider = @import("provider.zig"); +pub const agent = @import("agent.zig"); +pub const config = @import("config.zig"); +pub const sse = @import("sse.zig"); + +// Internal modules — not re-exported as public API +// pub const json = @import("json.zig"); +// pub const provider_openai = @import("provider_openai.zig"); diff --git a/libawl/src/sse.zig b/libawl/src/sse.zig new file mode 100644 index 0000000..150c3ea --- /dev/null +++ b/libawl/src/sse.zig @@ -0,0 +1,199 @@ +const std = @import("std"); + +/// Parsed SSE event data — the content after "data: " (prefix removed). +/// For multi-line data events, lines are joined with newlines per the SSE spec. +/// +/// Borrowed from the parser's internal buffer. Call `SSEParser.freeEvents()` +/// to free the events list when done (event data itself is borrowed and +/// does not need individual freeing). +const Event = []const u8; + +/// Incremental SSE line parser. +/// +/// The HTTP client delivers arbitrary-sized read buffers; this module +/// reassembles them into complete `data: ...\n\n` events. +/// +/// Event data is borrowed from the internal buffer. Call `freeEvents()` to +/// free the events list returned by `feed`; the event data itself is borrowed +/// from the parser's buffer and does not need individual freeing. +pub const SSEParser = struct { + buf: std.ArrayList(u8) = .empty, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) SSEParser { + return .{ + .allocator = allocator, + }; + } + + /// Feed a chunk of raw bytes. Returns a list of complete SSE events + /// found in the buffer. Returns an empty list if no complete events + /// are available yet. + /// + /// Event data is borrowed from the internal buffer. Call `freeEvents()` + /// on the returned list when done. + pub fn feed(self: *SSEParser, chunk: []const u8) ![]Event { + try self.buf.appendSlice(self.allocator, chunk); + + var events = std.ArrayList(Event).init(self.allocator); + errdefer events.deinit(); + + var write_pos: usize = 0; + var scan_pos: usize = 0; + + while (std.mem.indexOf(u8, self.buf.items[scan_pos..], "\n\n")) |rel_delim| { + const delim = scan_pos + rel_delim; + const event_bytes = self.buf.items[scan_pos..delim]; + + // Compact data lines in-place. Compacted data is always shorter + // than the original event bytes (we strip at least "data: " per + // line), so write_pos <= scan_pos and we never overwrite data + // we haven't yet read. + const data_start = write_pos; + var line_start: usize = 0; + var has_data = false; + + while (line_start < event_bytes.len) { + const newline = std.mem.indexOfScalarPos(u8, event_bytes, line_start, '\n') orelse event_bytes.len; + const line = event_bytes[line_start..newline]; + + if (std.mem.startsWith(u8, line, "data: ")) { + if (has_data) { + self.buf.items[write_pos] = '\n'; + write_pos += 1; + } + const content = line["data: ".len..]; + std.mem.copyForwards(u8, self.buf.items[write_pos .. write_pos + content.len], content); + write_pos += content.len; + has_data = true; + } + // Ignore other SSE fields (event:, id:, retry:) and comments (starting with :) + + line_start = newline + 1; + } + + if (has_data) { + try events.append(self.buf.items[data_start..write_pos]); + } + + scan_pos = delim + 2; + } + + // Shift remaining (incomplete) bytes to the front, retaining + // the backing allocation for reuse. + const remaining_len = self.buf.items.len - scan_pos; + if (remaining_len > 0 and scan_pos > 0) { + std.mem.copyForwards(u8, self.buf.items[0..remaining_len], self.buf.items[scan_pos..]); + } + self.buf.shrinkRetainingCapacity(remaining_len); + + return events.toOwnedSlice(); + } + + /// Free a list of events returned by `feed`. + /// Event data is borrowed from the parser's buffer and does not need + /// to be freed individually; only the events array itself is freed. + pub fn freeEvents(self: *SSEParser, events: []Event) void { + self.allocator.free(events); + } + + pub fn deinit(self: *SSEParser) void { + self.buf.deinit(self.allocator); + } +}; + +test "SSEParser - single complete event" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + const events = try parser.feed("data: hello\n\n"); + defer parser.freeEvents(events); + + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqualStrings("hello", events[0]); +} + +test "SSEParser - partial then complete" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + // Feed partial — no complete event yet + const events1 = try parser.feed("data: hel"); + defer parser.freeEvents(events1); + try std.testing.expectEqual(@as(usize, 0), events1.len); + + // Feed remainder — completes the event + const events2 = try parser.feed("lo\n\n"); + defer parser.freeEvents(events2); + try std.testing.expectEqual(@as(usize, 1), events2.len); + try std.testing.expectEqualStrings("hello", events2[0]); +} + +test "SSEParser - multiple events in single chunk" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + const events = try parser.feed("data: one\n\ndata: two\n\n"); + defer parser.freeEvents(events); + + try std.testing.expectEqual(@as(usize, 2), events.len); + try std.testing.expectEqualStrings("one", events[0]); + try std.testing.expectEqualStrings("two", events[1]); +} + +test "SSEParser - data DONE signal" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + const events = try parser.feed("data: [DONE]\n\n"); + defer parser.freeEvents(events); + + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqualStrings("[DONE]", events[0]); +} + +test "SSEParser - empty event (keep-alive)" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + // A blank line (just \n\n) is a keep-alive comment with no data + const events = try parser.feed("\n\n"); + defer parser.freeEvents(events); + try std.testing.expectEqual(@as(usize, 0), events.len); +} + +test "SSEParser - ignores non-data fields" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + const events = try parser.feed("event: message\ndata: payload\nid: 42\n\n"); + defer parser.freeEvents(events); + + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqualStrings("payload", events[0]); +} + +test "SSEParser - multi-line data joined with newline" { + const allocator = std.testing.allocator; + + var parser = SSEParser.init(allocator); + defer parser.deinit(); + + const events = try parser.feed("data: line1\ndata: line2\n\n"); + defer parser.freeEvents(events); + + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqualStrings("line1\nline2", events[0]); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..1356bdc --- /dev/null +++ b/src/main.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const libpanto = @import("libpanto"); + +pub fn main() !void { + const config = libpanto.config.Config.fromEnv() catch |err| switch (err) { + error.MissingApiKey => { + std.debug.print("panto: PANTO_API_KEY environment variable is required\n", .{}); + return; + }, + }; + + var debug_allocator = std.heap.DebugAllocator(.{}).init; + defer _ = debug_allocator.deinit(); + const alloc = debug_allocator.allocator(); + + var conv = libpanto.conversation.Conversation.init(alloc); + defer conv.deinit(); + + try conv.addSystemMessage("You are a helpful assistant."); + + const io = std.Io.Threaded.global_single_threaded.io(); + + var stdout_buffer: [4096]u8 = undefined; + var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); + const stdout = &stdout_file.interface; + + var stdin_buffer: [4096]u8 = undefined; + var stdin_file = std.Io.File.stdin().reader(io, &stdin_buffer); + const stdin = &stdin_file.interface; + + try stdout.print("panto — model: {s}\n> ", .{config.model}); + try stdout_file.flush(); + + while (true) { + const line = stdin.takeDelimiter('\n') catch |err| { + std.debug.print("Read error: {}\n", .{err}); + return; + }; + if (line) |text| { + if (text.len == 0) { + try stdout.print("> ", .{}); + try stdout_file.flush(); + continue; + } + // Copy the line since the reader buffer may be reused + const owned = try alloc.dupe(u8, text); + defer alloc.free(owned); + try conv.addUserMessage(owned); + // TODO: call agent.runStep() once provider is implemented + try stdout.print("(streaming not yet implemented)\n> ", .{}); + try stdout_file.flush(); + } else { + // EOF (Ctrl+D) + try stdout.print("\n", .{}); + try stdout_file.flush(); + break; + } + } +} -- cgit v1.3