From e8272efd9f71ad27a0e62b6b3fa3d13e99a6736f Mon Sep 17 00:00:00 2001 From: T Date: Mon, 25 May 2026 12:19:50 -0700 Subject: work --- AGENTS.md | 3 + build.zig | 6 +- build.zig.zon | 6 +- 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 --------------------------------------- libpanto/build.zig | 29 ++++++ libpanto/build.zig.zon | 10 ++ libpanto/src/agent.zig | 23 +++++ libpanto/src/config.zig | 25 +++++ libpanto/src/conversation.zig | 185 ++++++++++++++++++++++++++++++++++++ libpanto/src/json.zig | 4 + libpanto/src/provider.zig | 66 +++++++++++++ libpanto/src/provider_openai.zig | 6 ++ libpanto/src/root.zig | 9 ++ libpanto/src/sse.zig | 199 +++++++++++++++++++++++++++++++++++++++ src/main.zig | 12 +-- 24 files changed, 571 insertions(+), 580 deletions(-) create mode 100644 AGENTS.md delete mode 100644 libawl/build.zig delete mode 100644 libawl/build.zig.zon delete mode 100644 libawl/src/agent.zig delete mode 100644 libawl/src/config.zig delete mode 100644 libawl/src/conversation.zig delete mode 100644 libawl/src/json.zig delete mode 100644 libawl/src/provider.zig delete mode 100644 libawl/src/provider_openai.zig delete mode 100644 libawl/src/root.zig delete mode 100644 libawl/src/sse.zig create mode 100644 libpanto/build.zig create mode 100644 libpanto/build.zig.zon create mode 100644 libpanto/src/agent.zig create mode 100644 libpanto/src/config.zig create mode 100644 libpanto/src/conversation.zig create mode 100644 libpanto/src/json.zig create mode 100644 libpanto/src/provider.zig create mode 100644 libpanto/src/provider_openai.zig create mode 100644 libpanto/src/root.zig create mode 100644 libpanto/src/sse.zig diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1adfa7a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# pantograph + +Acronyms in CamelCase are fully capitalized: `APIStyle`, `HTTPClient`, `IDLookup`. Field names are snake_case: `api_style`, `base_url`, `model`. diff --git a/build.zig b/build.zig index f7d7149..c5ea966 100644 --- a/build.zig +++ b/build.zig @@ -4,7 +4,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const libpanto_dep = b.dependency("libpanto", .{ + const panto_lib_dep = b.dependency("panto", .{ .target = target, .optimize = optimize, }); @@ -15,7 +15,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); - exe_mod.addImport("libpanto", libpanto_dep.module("libpanto")); + exe_mod.addImport("panto", panto_lib_dep.module("panto")); const exe = b.addExecutable(.{ .name = "panto", @@ -39,7 +39,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); - test_mod.addImport("libpanto", libpanto_dep.module("libpanto")); + test_mod.addImport("panto", panto_lib_dep.module("panto")); const unit_tests = b.addTest(.{ .name = "panto-tests", diff --git a/build.zig.zon b/build.zig.zon index 71373e1..57dc3b5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,10 +1,10 @@ .{ - .fingerprint = 0x922eba47080389e5, + .fingerprint = 0x687da1a5c89a370f, .name = .panto, .version = "0.0.0", .dependencies = .{ - .libpanto = .{ - .path = "libpanto", + .panto = .{ + .path = "panto", }, }, .paths = .{ diff --git a/libawl/build.zig b/libawl/build.zig deleted file mode 100644 index 5e8442b..0000000 --- a/libawl/build.zig +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index f00be75..0000000 --- a/libawl/build.zig.zon +++ /dev/null @@ -1,10 +0,0 @@ -.{ - .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 deleted file mode 100644 index 7ebc058..0000000 --- a/libawl/src/agent.zig +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 8b6f147..0000000 --- a/libawl/src/config.zig +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 0e9a4e9..0000000 --- a/libawl/src/conversation.zig +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index 542391e..0000000 --- a/libawl/src/json.zig +++ /dev/null @@ -1,4 +0,0 @@ -// 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 deleted file mode 100644 index 8d6eea5..0000000 --- a/libawl/src/provider.zig +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index c459a9e..0000000 --- a/libawl/src/provider_openai.zig +++ /dev/null @@ -1,6 +0,0 @@ -// 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 deleted file mode 100644 index 72ceb97..0000000 --- a/libawl/src/root.zig +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 150c3ea..0000000 --- a/libawl/src/sse.zig +++ /dev/null @@ -1,199 +0,0 @@ -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/libpanto/build.zig b/libpanto/build.zig new file mode 100644 index 0000000..b70ae7b --- /dev/null +++ b/libpanto/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("panto", .{ + .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 = "panto-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/libpanto/build.zig.zon b/libpanto/build.zig.zon new file mode 100644 index 0000000..10d9111 --- /dev/null +++ b/libpanto/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .fingerprint = 0x687da1a5092d6c39, + .name = .panto, + .version = "0.0.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig new file mode 100644 index 0000000..7ebc058 --- /dev/null +++ b/libpanto/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/libpanto/src/config.zig b/libpanto/src/config.zig new file mode 100644 index 0000000..295f2c6 --- /dev/null +++ b/libpanto/src/config.zig @@ -0,0 +1,25 @@ +pub const APIStyle = enum { + anthropic, +}; + +pub const Config = struct { + api_style: APIStyle, + api_key: []const u8, + base_url: []const u8, + model: []const u8, +}; + +const t = @import("std").testing; + +test "Config - construct with all fields" { + const cfg = Config{ + .api_style = .anthropic, + .api_key = "sk-ant-test", + .base_url = "https://api.anthropic.com", + .model = "claude-sonnet-4-20250514", + }; + try t.expectEqual(APIStyle.anthropic, cfg.api_style); + try t.expectEqualStrings("sk-ant-test", cfg.api_key); + try t.expectEqualStrings("https://api.anthropic.com", cfg.base_url); + try t.expectEqualStrings("claude-sonnet-4-20250514", cfg.model); +} diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig new file mode 100644 index 0000000..0e9a4e9 --- /dev/null +++ b/libpanto/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/libpanto/src/json.zig b/libpanto/src/json.zig new file mode 100644 index 0000000..542391e --- /dev/null +++ b/libpanto/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/libpanto/src/provider.zig b/libpanto/src/provider.zig new file mode 100644 index 0000000..8d6eea5 --- /dev/null +++ b/libpanto/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/libpanto/src/provider_openai.zig b/libpanto/src/provider_openai.zig new file mode 100644 index 0000000..c459a9e --- /dev/null +++ b/libpanto/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/libpanto/src/root.zig b/libpanto/src/root.zig new file mode 100644 index 0000000..72ceb97 --- /dev/null +++ b/libpanto/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/libpanto/src/sse.zig b/libpanto/src/sse.zig new file mode 100644 index 0000000..150c3ea --- /dev/null +++ b/libpanto/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 index 1356bdc..4f9c490 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,12 +1,12 @@ const std = @import("std"); -const libpanto = @import("libpanto"); +const libpanto = @import("panto"); 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; - }, + const config = libpanto.config.Config{ + .api_style = .anthropic, + .api_key = "test", + .base_url = "https://api.anthropic.com", + .model = "claude-sonnet-4-20250514", }; var debug_allocator = std.heap.DebugAllocator(.{}).init; -- cgit v1.3