summaryrefslogtreecommitdiff
path: root/libawl/src
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-04-26 11:09:37 -0600
committerT <t@tjp.lol>2026-05-25 11:48:47 -0700
commit52b2ca78aed7950af27d4865aee65da781514a99 (patch)
tree24f512e67afd677b6c0d8a8064819e8f5332d28d /libawl/src
parentd3d5a6295a67bdc99e35e5bfd77359d98a850c3f (diff)
Initial work, and name change to pantograph
Diffstat (limited to 'libawl/src')
-rw-r--r--libawl/src/agent.zig23
-rw-r--r--libawl/src/config.zig37
-rw-r--r--libawl/src/conversation.zig185
-rw-r--r--libawl/src/json.zig4
-rw-r--r--libawl/src/provider.zig66
-rw-r--r--libawl/src/provider_openai.zig6
-rw-r--r--libawl/src/root.zig9
-rw-r--r--libawl/src/sse.zig199
8 files changed, 529 insertions, 0 deletions
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]);
+}