From 576891dc2ec4d917932a4c396471d4bbbad90c8e Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 08:04:04 -0600 Subject: Finish the hard parts of the lua makeover - bundle luarocks source in the panto binary - bootstrap process (intended for first `panto` run): - make ~/.local/share/panto/... - write out luarocks sources into it - run luarocks to install luv - new `panto bootstrap` command just runs the bootstrap - `panto bootstrap --force` removes everything and re-bootstraps - new `panto lua` command just runs panto's embedded lua --- docs/phase-1.md | 449 -------------------------------------------------------- 1 file changed, 449 deletions(-) delete mode 100644 docs/phase-1.md (limited to 'docs/phase-1.md') diff --git a/docs/phase-1.md b/docs/phase-1.md deleted file mode 100644 index 6b149aa..0000000 --- a/docs/phase-1.md +++ /dev/null @@ -1,449 +0,0 @@ -# Phase 1: libpanto — Minimal Chat Library - -**Status: complete.** Streaming chat works end-to-end against OpenAI-compatible APIs; conversation history persists across turns; thinking blocks (`reasoning_content` / `reasoning`) are streamed; `reasoning_effort` is configurable. Open questions resolved: thinking support implemented; mid-stream errors propagate via Zig errors; connections are one-per-turn intentionally (uniform reentry into each turn); long-conversation memory deferred to a later phase. Session persistence is phase 4. - -## Goal - -A Zig library that can hold a streaming conversation with an LLM via an OpenAI-compatible API. No tools, no extensions — just chat. Includes a minimal CLI for live testing. - -## Deliverable - -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 `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. - -## What is usable at the end - -| Capability | How to exercise it | -|---|---| -| 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 | -| Conversation persists across turns | Follow-up messages include prior history | - -## What is explicitly out of scope - -- Tools and tool-use (phase 3+) -- Extensions and extension API (phase 3+) -- C ABI (phase 3+, when needed for Lua extensions) -- Anthropic provider (phase 2) -- Disk persistence / session save (later phase) -- Server/proxy mode (future, undefined phase) -- System prompt construction framework (later phase — the model supports system messages, but no opinionated assembly system yet) - ---- - -## Data Model - -### TextualBlock (shared streaming buffer) - -``` -TextualBlock = struct { - buf: std.ArrayList(u8), - allocator: std.mem.Allocator, - - pub fn init(allocator: std.mem.Allocator) TextualBlock - pub fn content(self: *const TextualBlock) []const u8 // self.buf.items - pub fn append(self: *TextualBlock, delta: []const u8) !void - pub fn deinit(self: *TextualBlock) void // self.buf.deinit() -} -``` - -`Text` and `Thinking` blocks both use `TextualBlock` as their payload. They share the same append-based streaming behavior — deltas arrive incrementally and are appended via an internal `ArrayList(u8)`, giving amortized O(1) appends and avoiding the O(n²) re-copying that would result from storing `[]const u8` slices. - -The `TextualBlock` stores its own allocator reference so that `deinit()` needs no external context. Each `TextualBlock` has an `init()`/`deinit()` pair. This also means a `ContentBlock` can clean itself up without the caller providing an allocator. - -### ContentBlock (tagged union) - -``` -ContentBlock = union(enum) { - Text: TextualBlock, - Thinking: TextualBlock, - ToolUse: ToolUseBlock, // phase 3+ - ToolResult: ToolResultBlock, // phase 3+ - - pub fn deinit(self: *ContentBlock) void { - switch (self.*) { - .Text, .Thinking => |*b| b.deinit(), - .ToolUse => |b| { /* free id, name, input */ }, - .ToolResult => |b| { /* free tool_use_id, content */ }, - } - } -} - -ToolUseBlock = struct { - id: []const u8, // owned copy - name: []const u8, // owned copy - input: []const u8, // raw JSON bytes, owned copy -} - -ToolResultBlock = struct { - tool_use_id: []const u8, // owned copy - content: []const u8, // owned copy -} -``` - -`ToolUse` and `ToolResult` are defined in the model now but not populated or processed until the extensions phase. This avoids a model refactor later — the types exist, we just never encounter them in phase 1. - -The `input` field of `ToolUse` is stored as raw JSON bytes (`[]const u8`) rather than a parsed structure. We are not in the business of understanding tool input schemas; we pass them through. - -`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 `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: -``` -ToolUseBlock = struct { - id: []const u8, // owned copy, from onBlockStart metadata - name: []const u8, // owned copy, from onBlockStart metadata - input: TextualBlock, // accumulated from onContentDelta -} - -ToolResultBlock = struct { - tool_use_id: []const u8, // owned copy - content: TextualBlock, // accumulated content -} -``` - -**Memory discipline**: When a `ContentBlock` is moved into a `Message`'s content list (stored in `std.ArrayList(ContentBlock)`), the TextualBlock's internal ArrayList buffer pointer remains valid — it points to the same heap allocation. The caller must ensure each block's `deinit()` is called exactly once, and must not copy a ContentBlock without clearing the source (standard Zig move semantics). - -### Message - -``` -Message = { - role: enum { system, user, assistant }, - content: []ContentBlock, -} -``` - -A system message may contain multiple `Text` blocks. When serializing to Anthropic's API (phase 2), these are concatenated into the single system prompt string. - -An assistant message is assembled incrementally during streaming. A user message containing tool results (phase 3+) naturally groups multiple `ToolResult` blocks. - -### Conversation - -``` -Conversation = { - messages: std.ArrayList(Message), - allocator: std.mem.Allocator, -} -``` - -Ordered list of messages. Methods: - -- `init(allocator)` → Conversation -- `addSystemMessage(text)` → appends `Message{ .system, [TextBlock(text)] }` -- `addUserMessage(text)` → appends `Message{ .user, [TextBlock(text)] }` -- `addAssistantMessage(blocks)` → appends `Message{ .assistant, blocks }` (called by agent loop after streaming completes) -- `deinit()` → frees all owned memory - -All `[]const u8` fields in ContentBlocks and Messages are owned by the Conversation and freed on `deinit()`. Content is stored as copies, not slices into external buffers. `TextualBlock` fields back their content with a heap-allocated `ArrayList(u8)` that grows incrementally during streaming and is freed on `deinit()`. - ---- - -## Module Structure - -``` -src/ - root.zig // public API re-exports - conversation.zig // Message, ContentBlock, Conversation - provider.zig // Provider interface, StreamEvent, StreamResult - provider_openai.zig // OpenAI-compatible implementation - sse.zig // SSE line parser - agent.zig // Agent loop: runStep, Receiver interface - config.zig // Config struct (api_key, base_url, model) - json.zig // Serialization helpers (model → wire JSON, deltas → ContentBlocks) -``` - -### `conversation.zig` - -Defines `Message`, `ContentBlock`, `Conversation`. All serialization to/from provider wire formats lives in `json.zig` — conversation.zig is pure data structure. - -Tests: create conversations, add messages, verify content, free without leaks. - -### `provider.zig` - -Defines the `Provider` interface: - -``` -Provider = struct { - ptr: *anyopaque, - vtable: *const VTable, - - VTable = struct { - streamStep: *const fn(*anyopaque, conversation: *Conversation, receiver: *Receiver) anyerror!void, - deinit: *const fn(*anyopaque) void, - }; - - pub fn streamStep(self, conversation, receiver) !void - pub fn deinit(self) void -}; -``` - -And the `Receiver` interface for streaming callbacks: - -``` -Receiver = struct { - ptr: *anyopaque, - vtable: *const ReceiverVTable, - - ReceiverVTable = struct { - onMessageStart: *const fn(*anyopaque, role: MessageRole) void, - onBlockStart: *const fn(*anyopaque, block_type: ContentBlockType, index: usize, meta: ?BlockMeta) void, - onContentDelta: *const fn(*anyopaque, block_index: usize, delta: []const u8) void, - onBlockComplete: *const fn(*anyopaque, block_index: usize, block: ContentBlock) void, - onMessageComplete:*const fn(*anyopaque, message: Message) void, - }; - - pub fn onMessageStart(self, role) void - pub fn onBlockStart(self, block_type, index, meta) void - pub fn onContentDelta(self, block_index, delta) void - pub fn onBlockComplete(self, block_index, block) void - pub fn onMessageComplete(self, message) void -}; - -BlockMeta = struct { - // Only populated for ToolUse blocks. Null for Text/Thinking. - tool_id: ?[]const u8, - tool_name: ?[]const u8, -}; -``` - -**Callback contract:** - -- 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` — 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. - -This uniform callback sequence means the TUI and agent loop don't need to know which provider is active. Anthropic (phase 2) maps its structured events directly to these callbacks; OpenAI synthesizes block boundaries from delta field transitions (see below). - -### `provider_openai.zig` - -Implements `Provider` for OpenAI-compatible APIs. - -- Converts `Conversation` → OpenAI wire JSON (see [OpenAI serialization](#openai-serialization) below) -- Makes HTTP POST to `{base_url}/chat/completions` with `stream: true` -- Reads SSE events, parses each `data: {...}` line as complete JSON -- Synthesizes block boundaries from delta field transitions (OpenAI has no explicit block boundary events) -- Calls the full Receiver callback sequence (onMessageStart → onBlockStart → onContentDelta → onBlockComplete → onMessageComplete) -- Accumulates deltas into ContentBlocks via TextualBlocks - -Construction: - -``` -OpenAIProvider.init(allocator, config) !OpenAIProvider -``` - -### OpenAI block boundary synthesis - -OpenAI's streaming deltas have no explicit block boundaries. The provider tracks a state machine to infer when blocks start and end: - -``` -StreamingState = struct { - active_block_type: enum { none, thinking, text, tool_use }, - active_block_index: usize, - // assembly buffers per block -} - -On each SSE event: - 1. If delta.role == "assistant" → emit onMessageStart(.assistant) - 2. If delta.reasoning_content present: - - If active_block_type != .thinking: - - If active_block_type != .none → emit onBlockComplete for prior block - - Emit onBlockStart(.Thinking, index, null) - - active_block_type = .thinking - - Emit onContentDelta(index, delta.reasoning_content) - 3. If delta.content present: - - If active_block_type != .text: - - If active_block_type != .none → emit onBlockComplete for prior block - - Emit onBlockStart(.Text, index, null) - - active_block_type = .text - - Emit onContentDelta(index, delta.content) - 4. If delta.tool_calls present: - - If active_block_type != .tool_use: - - If active_block_type != .none → emit onBlockComplete for prior block - - Emit onBlockStart(.ToolUse, index, .{ .tool_id = ..., .tool_name = ... }) - - active_block_type = .tool_use - - Emit onContentDelta(index, delta.tool_calls[].function.arguments) - 5. If finish_reason != null: - - If active_block_type != .none → emit onBlockComplete for current block - - Emit onMessageComplete(assembled_message) -``` - -A transition in `active_block_type` means the previous block is done and a new one has started. The state machine also handles the case where the same block type appears again after an intervening type (e.g., thinking → text → thinking), which would open a new Thinking block at a new index. - -### `sse.zig` - -Incremental SSE line parser. The HTTP client delivers arbitrary-sized read buffers; this module reassembles them into complete `data: ...\n\n` events. - -``` -SSEParser = struct { - buf: std.ArrayList(u8), - - pub fn init(allocator) SSEParser - pub fn feed(self, chunk: []const u8) ![]const []const u8 // returns slice of complete event strings - pub fn deinit(self) void -}; -``` - -`feed()` may return zero events (partial line buffered) or multiple events (chunk contained several). The caller does not need to worry about line boundaries. - -Tests: feed partial chunks, verify events emitted at correct boundaries; multi-event in single chunk; empty lines; `data: [DONE]`. - -### `json.zig` - -Two responsibilities: - -1. **Serialize Conversation → OpenAI request body** — Convert our `Message`/`ContentBlock` model into the JSON shape OpenAI expects. See below. -2. **Parse SSE chunk deltas → ContentBlock updates** — Each SSE event's JSON contains a `choices[0].delta` object. Extract text/thinking content from it. - -### `agent.zig` - -The agent loop. In phase 1, it's simple: - -``` -Agent = struct { - provider: Provider, - allocator: std.mem.Allocator, - - pub fn init(allocator, provider) Agent - pub fn runStep(self, conversation: *Conversation, receiver: *Receiver) !void - pub fn deinit(self) void -}; -``` - -`runStep` does: -1. Call `provider.streamStep(conversation, receiver)` — this streams the response and calls the full Receiver callback sequence on the receiver -2. The `onMessageComplete` callback appends the finished Message to the conversation (the agent itself can wire this, or the caller handles it — TBD during implementation) - -In later phases, `runStep` gains the tool-call loop: check for ToolUse blocks, execute them, feed results back, call provider again. But the shape stays the same — one `runStep` invocation carries the conversation through a full agent turn. - -### `config.zig` - -``` -Config = struct { - api_key: []const u8, - base_url: []const u8, // e.g. "https://api.openai.com/v1" - model: []const u8, // e.g. "gpt-4o" -}; -``` - -Populated from environment variables (`PANTO_API_KEY`, `PANTO_BASE_URL`, `PANTO_MODEL`) with defaults for base_url and model. - -### `root.zig` - -Public API. Re-exports the types and functions that external Zig code needs: - -``` -pub const conversation = @import("conversation.zig"); -pub const provider = @import("provider.zig"); -pub const agent = @import("agent.zig"); -pub const config = @import("config.zig"); -``` - -Does not re-export provider_openai, sse, or json — those are internal. - ---- - -## OpenAI Serialization - -### Request - -Our `Conversation` → OpenAI `chat/completions` request body: - -``` -{ - "model": config.model, - "stream": true, - "messages": [ - // For each Message in conversation: - // - // role=.system → { "role": "system", "content": "" } - // role=.user → { "role": "user", "content": "" } - // (ToolResult blocks pulled out into separate role:tool messages in phase 3+) - // role=.assistant → { "role": "assistant", "content": [ - // ...text blocks as { "type": "text", "text": "..." }, - // ...thinking blocks as { "type": "thinking", "thinking": "..." }, - // ...tool_use blocks become function_call/function entries in phase 3+ - // ] } - ] -} -``` - -For phase 1, all content blocks we encounter are `Text` or `Thinking`, so serialization is straightforward. - -### Response (streaming) - -Each SSE event is a complete JSON object: - -``` -data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} -data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} -data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]} -data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} -data: [DONE] -``` - -We parse each event's `choices[0].delta` and drive the block boundary state machine: -- `delta.role == "assistant"` → emit onMessageStart, marks the start of a new assistant message -- `delta.reasoning_content` → transition to Thinking block if needed, append via onContentDelta -- `delta.content` → transition to Text block if needed, append via onContentDelta -- `delta.tool_calls` → transition to ToolUse block if needed, append arguments via onContentDelta (phase 3+) - -The `finish_reason: "stop"` signals stream end → emit onBlockComplete for any active block, then onMessageComplete. - ---- - -## Minimal CLI - -``` -panto/ - src/ - main.zig // CLI entry point -``` - -Behavior: -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 libpanto against a real API. - ---- - -## Testing Strategy - -### Unit tests (automated, per module) - -| Module | What to test | -|---|---| -| `conversation.zig` | Create conversation, add messages of each role, verify content block storage, free without leaks | -| `sse.zig` | Feed partial chunks, verify event boundaries; multi-event chunks; `data: [DONE]`; empty lines between events | -| `json.zig` | Serialize conversation → OpenAI JSON; parse delta JSON objects → content updates | -| `config.zig` | Parse from env vars; defaults for missing optional fields | - -### Integration test (manual) - -- 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?") - ---- - -## Open Questions (to resolve during implementation) - -1. **Thinking token support in OpenAI API**: OpenAI's `reasoning_content` field in streaming deltas is not universally present across models/endpoints. We need to handle its absence gracefully (just skip it, don't crash). -2. **Error handling in streams**: Mid-stream HTTP errors, rate limiting, truncated responses. How do we represent these to the caller? An `onError` callback on the Receiver seems likely. -3. **HTTP connection lifecycle**: Does `std.http.Client` support long-lived streaming connections cleanly? We may need to manage connection pooling or timeouts. -4. **Memory strategy for long conversations**: We're storing full message content in memory. For phase 1 this is fine, but we should define the interface so a later phase can introduce message summarization or offloading without changing the agent loop. -- cgit v1.3