summaryrefslogtreecommitdiff
path: root/libpanto-lua/src
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto-lua/src')
-rw-r--r--libpanto-lua/src/module.zig368
1 files changed, 362 insertions, 6 deletions
diff --git a/libpanto-lua/src/module.zig b/libpanto-lua/src/module.zig
index 7f7f838..f905765 100644
--- a/libpanto-lua/src/module.zig
+++ b/libpanto-lua/src/module.zig
@@ -146,6 +146,7 @@ fn releaseIo() void {
const AGENT_MT = "panto.Agent";
const STREAM_MT = "panto.Stream";
const CONV_MT = "panto.Conversation";
+const STORE_MT = "panto.SessionStore";
/// Boxed `*Agent` userdata.
///
@@ -171,7 +172,7 @@ const AgentBox = struct {
config: *panto.Config,
/// Superseded configs, freed at teardown (see the struct doc).
old_configs: std.ArrayList(*panto.Config),
- store_impl: panto.NullStore,
+ store_ref: c_int = c.LUA_NOREF,
/// Arena owning every config string (api_key/base_url/model/prompt…)
/// across the live config and all superseded ones. Freed at teardown.
string_arena: std.heap.ArenaAllocator,
@@ -191,6 +192,10 @@ const AgentBox = struct {
for (self.old_configs.items) |cfg| c_allocator.destroy(cfg);
self.old_configs.deinit(c_allocator);
self.string_arena.deinit();
+ if (self.store_ref != c.LUA_NOREF) {
+ c.luaL_unref(L, LUA_REGISTRYINDEX, self.store_ref);
+ self.store_ref = c.LUA_NOREF;
+ }
releaseIo();
}
};
@@ -229,8 +234,56 @@ const StreamBox = struct {
///
/// `view` is the pointer methods operate through: for `.owned` it is
/// `&self.inner`; for `.borrowed` it is `&agent.conversation`.
+const StoreBox = struct {
+ impl: union(enum) {
+ null_store: panto.NullStore,
+ fs_jsonl: panto.FileSystemJSONLStore,
+ lua: LuaSessionStore,
+ },
+ closed: bool = false,
+
+ fn store(self: *StoreBox) panto.SessionStore {
+ return switch (self.impl) {
+ .null_store => |*s| s.store(),
+ .fs_jsonl => |*s| s.store(),
+ .lua => |*s| s.store(),
+ };
+ }
+
+ fn deinit(self: *StoreBox, L: *c.lua_State) void {
+ if (self.closed) return;
+ self.closed = true;
+ switch (self.impl) {
+ .null_store => {},
+ .fs_jsonl => |*s| s.deinit(),
+ .lua => |*s| s.deinit(L),
+ }
+ }
+};
+
+const LuaSessionStore = struct {
+ L: *c.lua_State,
+ table_ref: c_int,
+
+ fn init(L: *c.lua_State, idx: c_int) LuaSessionStore {
+ c.lua_pushvalue(L, idx);
+ return .{ .L = L, .table_ref = c.luaL_ref(L, LUA_REGISTRYINDEX) };
+ }
+
+ fn deinit(self: *LuaSessionStore, L: *c.lua_State) void {
+ if (self.table_ref != c.LUA_NOREF) {
+ c.luaL_unref(L, LUA_REGISTRYINDEX, self.table_ref);
+ self.table_ref = c.LUA_NOREF;
+ }
+ }
+
+ fn store(self: *LuaSessionStore) panto.SessionStore {
+ return .{ .ptr = self, .vtable = &lua_store_vtable };
+ }
+};
+
const ConvBox = struct {
- state: enum { owned, adopted, borrowed },
+ state: enum { owned, adopted, borrowed },
/// Storage for an owned conversation. Unused (undefined) when borrowed.
inner: panto.Conversation,
/// The conversation methods act on. Always valid while usable.
@@ -268,11 +321,15 @@ pub export fn luaopen_panto(L_opt: ?*c.lua_State) callconv(.c) c_int {
registerMetatables(L);
- c.lua_createtable(L, 0, 3);
+ c.lua_createtable(L, 0, 5);
c.lua_pushcclosure(L, agentNew, 0);
c.lua_setfield(L, -2, "agent");
c.lua_pushcclosure(L, conversationNew, 0);
c.lua_setfield(L, -2, "conversation");
+ c.lua_pushcclosure(L, nullStoreNew, 0);
+ c.lua_setfield(L, -2, "null_store");
+ c.lua_pushcclosure(L, fsJSONLStoreNew, 0);
+ c.lua_setfield(L, -2, "file_system_jsonl_store");
_ = c.lua_pushstring(L, "libpanto-lua 0.0.0 (Lua 5.4)");
c.lua_setfield(L, -2, "_VERSION");
return 1;
@@ -307,6 +364,17 @@ fn registerMetatables(L: *c.lua_State) void {
}
c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (c.luaL_newmetatable(L, STORE_MT) != 0) {
+ c.lua_createtable(L, 0, 5); // methods
+ setMethod(L, "list", storeList);
+ setMethod(L, "resolve", storeResolve);
+ setMethod(L, "latest", storeLatest);
+ setMethod(L, "load", storeLoad);
+ c.lua_setfield(L, -2, "__index");
+ setMethod(L, "__gc", storeGc);
+ }
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+
if (c.luaL_newmetatable(L, CONV_MT) != 0) {
c.lua_createtable(L, 0, 9); // methods
setMethod(L, "add_system_message", convAddSystemMessage);
@@ -344,7 +412,7 @@ fn agentNew(L_opt: ?*c.lua_State) callconv(.c) c_int {
.agent = undefined,
.config = undefined,
.old_configs = .empty,
- .store_impl = undefined,
+ .store_ref = c.LUA_NOREF,
.string_arena = std.heap.ArenaAllocator.init(c_allocator),
.tools = null,
.closed = false,
@@ -386,16 +454,22 @@ fn buildAgent(L: *c.lua_State, box: *AgentBox) ConfigError!void {
retainIo();
errdefer releaseIo();
+ const store_box = try parseStore(L, 1);
+ const session = store_box.store().create();
+
box.config = cfg;
- box.store_impl = panto.NullStore.init(c_allocator);
box.agent = panto.Agent.init(
c_allocator,
io_ctx.io,
box.config,
- box.store_impl.store().create(),
+ session,
if (adopt_box) |cb| cb.inner else null,
) catch return error.AgentInit;
+ // Pin the store after Agent.init succeeds; the agent/session borrow it.
+ c.lua_pushvalue(L, -1);
+ box.store_ref = c.luaL_ref(L, LUA_REGISTRYINDEX);
+
// Ownership of the inner conversation moved into the agent; neuter the
// source box so its `__gc` won't double-free.
if (adopt_box) |cb| {
@@ -574,6 +648,135 @@ fn conversationNew(L_opt: ?*c.lua_State) callconv(.c) c_int {
return 1;
}
+// ===========================================================================
+// panto.null_store() / panto.file_system_jsonl_store{} / custom Lua stores
+// ===========================================================================
+
+fn nullStoreNew(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const ud = c.lua_newuserdatauv(L, @sizeOf(StoreBox), 0) orelse
+ return luaErr(L, "panto.null_store: out of memory");
+ const sb: *StoreBox = @ptrCast(@alignCast(ud));
+ sb.* = .{ .impl = .{ .null_store = panto.NullStore.init(c_allocator) } };
+ _ = c.luaL_setmetatable(L, STORE_MT);
+ return 1;
+}
+
+fn fsJSONLStoreNew(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ c.luaL_checktype(L, 1, T_TABLE);
+ const dir = requiredString(L, 1, "dir") catch return luaErr(L, "panto.file_system_jsonl_store: missing dir");
+ const metadata = optString(L, 1, "metadata") catch return luaErr(L, "panto.file_system_jsonl_store: bad metadata");
+ const store = panto.FileSystemJSONLStore.initWithMetadata(c_allocator, io_ctx.io, dir, metadata) catch
+ return luaErr(L, "panto.file_system_jsonl_store: failed to initialize store");
+
+ const ud = c.lua_newuserdatauv(L, @sizeOf(StoreBox), 0) orelse {
+ var s = store;
+ s.deinit();
+ return luaErr(L, "panto.file_system_jsonl_store: out of memory");
+ };
+ const sb: *StoreBox = @ptrCast(@alignCast(ud));
+ sb.* = .{ .impl = .{ .fs_jsonl = store } };
+ _ = c.luaL_setmetatable(L, STORE_MT);
+ return 1;
+}
+
+fn parseStore(L: *c.lua_State, agent_tbl: c_int) ConfigError!*StoreBox {
+ const t = c.lua_getfield(L, agent_tbl, "store");
+ if (t == T_NIL) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (nullStoreNew(L) != 1) return error.OutOfMemory;
+ return checkStore(L, -1);
+ }
+ if (c.luaL_testudata(L, -1, STORE_MT)) |ud| return @ptrCast(@alignCast(ud));
+ if (t != T_TABLE) return error.BadField;
+
+ const ud = c.lua_newuserdatauv(L, @sizeOf(StoreBox), 0) orelse return error.OutOfMemory;
+ const sb: *StoreBox = @ptrCast(@alignCast(ud));
+ sb.* = .{ .impl = .{ .lua = LuaSessionStore.init(L, -2) } };
+ _ = c.luaL_setmetatable(L, STORE_MT);
+ return sb;
+}
+
+fn checkStore(L: *c.lua_State, idx: c_int) *StoreBox {
+ return @ptrCast(@alignCast(c.luaL_checkudata(L, idx, STORE_MT)));
+}
+
+fn storeGc(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ checkStore(L, 1).deinit(L);
+ return 0;
+}
+
+fn storeList(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const sb = checkStore(L, 1);
+ const infos = sb.store().list() catch return luaErr(L, "panto: store list failed");
+ defer sb.store().freeSessionInfos(infos);
+ pushSessionInfoList(L, infos);
+ return 1;
+}
+
+fn storeResolve(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const sb = checkStore(L, 1);
+ var len: usize = 0;
+ const ptr = c.luaL_checklstring(L, 2, &len);
+ const s = sb.store().resolve(ptr[0..len]) catch return luaErr(L, "panto: store resolve failed");
+ if (s) |sess| pushSessionInfo(L, sess.info) else c.lua_pushnil(L);
+ return 1;
+}
+
+fn storeLatest(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const sb = checkStore(L, 1);
+ const s = sb.store().latest() catch return luaErr(L, "panto: store latest failed");
+ if (s) |sess| pushSessionInfo(L, sess.info) else c.lua_pushnil(L);
+ return 1;
+}
+
+fn storeLoad(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const sb = checkStore(L, 1);
+ var len: usize = 0;
+ const ptr = c.luaL_checklstring(L, 2, &len);
+ const conv = (sb.store().load(ptr[0..len]) catch return luaErr(L, "panto: store load failed")) orelse {
+ c.lua_pushnil(L);
+ return 1;
+ };
+ pushConversationOwned(L, conv);
+ return 1;
+}
+
+fn pushConversationOwned(L: *c.lua_State, conv: panto.Conversation) void {
+ const ud = c.lua_newuserdatauv(L, @sizeOf(ConvBox), 0).?;
+ const cb: *ConvBox = @ptrCast(@alignCast(ud));
+ cb.* = .{ .state = .owned, .inner = conv, .view = undefined, .agent_ref = c.LUA_NOREF };
+ cb.view = &cb.inner;
+ _ = c.luaL_setmetatable(L, CONV_MT);
+}
+
+fn pushSessionInfoList(L: *c.lua_State, infos: []panto.SessionInfo) void {
+ c.lua_createtable(L, @intCast(infos.len), 0);
+ for (infos, 0..) |info, i| {
+ pushSessionInfo(L, info);
+ c.lua_rawseti(L, -2, @intCast(i + 1));
+ }
+}
+
+fn pushSessionInfo(L: *c.lua_State, info: panto.SessionInfo) void {
+ c.lua_createtable(L, 0, 9);
+ setStringField(L, "id", info.id);
+ setStringField(L, "created", info.created);
+ setStringField(L, "modified", info.modified);
+ setIntField(L, "message_count", @intCast(info.message_count));
+ setStringField(L, "last_user_message", info.last_user_message);
+ setStringField(L, "api_style", @tagName(info.api_style));
+ setStringField(L, "base_url", info.base_url);
+ setStringField(L, "model", info.model);
+ setStringField(L, "reasoning", @tagName(info.reasoning));
+}
+
fn checkConv(L: *c.lua_State, idx: c_int) *ConvBox {
return @ptrCast(@alignCast(c.luaL_checkudata(L, idx, CONV_MT)));
}
@@ -1673,6 +1876,159 @@ fn contentBlockTypeName(block: panto.ContentBlock) []const u8 {
// package has no cross-package dependency on the CLI)
// ===========================================================================
+const lua_store_vtable: panto.SessionStore.VTable = .{
+ .create = luaStoreCreate,
+ .list = luaStoreList,
+ .freeSessionInfos = luaStoreFreeInfos,
+ .resolve = luaStoreResolve,
+ .latest = luaStoreLatest,
+ .load = luaStoreLoad,
+ .appendMessages = luaStoreAppend,
+};
+
+fn luaStoreCreate(ctx: *anyopaque) panto.Session {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ callStoreMethod(self, "create", 0) catch return fallbackNullSession();
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ return sessionFromLuaInfo(self, -1) catch fallbackNullSession();
+}
+
+fn luaStoreList(ctx: *anyopaque) anyerror![]panto.SessionInfo {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ try callStoreMethod(self, "list", 0);
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ if (c.lua_type(self.L, -1) != T_TABLE) return error.BadLuaStore;
+ const n: usize = @intCast(c.lua_rawlen(self.L, -1));
+ const out = try c_allocator.alloc(panto.SessionInfo, n);
+ var built: usize = 0;
+ errdefer { for (out[0..built]) |i| i.deinit(c_allocator); c_allocator.free(out); }
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(self.L, -1, @intCast(i));
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ out[built] = try parseSessionInfo(self.L, -1);
+ built += 1;
+ }
+ return out;
+}
+
+fn luaStoreFreeInfos(_: *anyopaque, infos: []panto.SessionInfo) void {
+ for (infos) |i| i.deinit(c_allocator);
+ c_allocator.free(infos);
+}
+
+fn luaStoreResolve(ctx: *anyopaque, id: []const u8) anyerror!?panto.Session {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ _ = c.lua_pushlstring(self.L, id.ptr, id.len);
+ try callStoreMethod(self, "resolve", 1);
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ if (c.lua_type(self.L, -1) == T_NIL) return null;
+ return try sessionFromLuaInfo(self, -1);
+}
+
+fn luaStoreLatest(ctx: *anyopaque) anyerror!?panto.Session {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ try callStoreMethod(self, "latest", 0);
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ if (c.lua_type(self.L, -1) == T_NIL) return null;
+ return try sessionFromLuaInfo(self, -1);
+}
+
+fn luaStoreLoad(ctx: *anyopaque, id: []const u8) anyerror!?panto.Conversation {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ _ = c.lua_pushlstring(self.L, id.ptr, id.len);
+ try callStoreMethod(self, "load", 1);
+ defer c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+ if (c.lua_type(self.L, -1) == T_NIL) return null;
+ const cb = @as(?*ConvBox, if (c.luaL_testudata(self.L, -1, CONV_MT)) |ud| @ptrCast(@alignCast(ud)) else null) orelse return error.BadLuaStore;
+ if (cb.state != .owned) return error.BadLuaStore;
+ const conv = cb.inner;
+ cb.inner = panto.Conversation.init(c_allocator);
+ return conv;
+}
+
+fn luaStoreAppend(ctx: *anyopaque, session_id: []const u8, messages: []panto.PersistentMessage) anyerror!void {
+ const self: *LuaSessionStore = @ptrCast(@alignCast(ctx));
+ _ = c.lua_pushlstring(self.L, session_id.ptr, session_id.len);
+ c.lua_createtable(self.L, @intCast(messages.len), 0);
+ for (messages, 0..) |m, i| {
+ pushPersistentMessage(self.L, m);
+ c.lua_rawseti(self.L, -2, @intCast(i + 1));
+ }
+ try callStoreMethod(self, "append_messages", 2);
+ c.lua_settop(self.L, c.lua_gettop(self.L) - 1);
+}
+
+fn callStoreMethod(self: *LuaSessionStore, name: [:0]const u8, nargs: c_int) !void {
+ const L = self.L;
+ _ = c.lua_rawgeti(L, LUA_REGISTRYINDEX, @intCast(self.table_ref));
+ const tt = c.lua_getfield(L, -1, name.ptr);
+ if (tt != T_FUNCTION) return error.BadLuaStore;
+ c.lua_insert(L, -(nargs + 2));
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (c.lua_pcallk(L, nargs, 1, 0, 0, null) != 0) return error.BadLuaStore;
+}
+
+fn sessionFromLuaInfo(self: *LuaSessionStore, idx: c_int) !panto.Session {
+ return .{ .info = try parseSessionInfo(self.L, idx), .store = self.store() };
+}
+
+fn parseSessionInfo(L: *c.lua_State, idx: c_int) !panto.SessionInfo {
+ if (c.lua_type(L, idx) != T_TABLE) return error.BadLuaStore;
+ return .{
+ .id = try dupLuaField(L, idx, "id"),
+ .created = try dupLuaField(L, idx, "created"),
+ .modified = try dupLuaField(L, idx, "modified"),
+ .message_count = @intCast(intLuaField(L, idx, "message_count")),
+ .last_user_message = try dupLuaField(L, idx, "last_user_message"),
+ .api_style = parseEnum(panto.APIStyle, luaFieldOrDefault(L, idx, "api_style", "openai_chat") catch "openai_chat") orelse .openai_chat,
+ .base_url = try dupLuaField(L, idx, "base_url"),
+ .model = try dupLuaField(L, idx, "model"),
+ .reasoning = parseEnum(panto.ReasoningEffort, luaFieldOrDefault(L, idx, "reasoning", "default") catch "default") orelse .default,
+ };
+}
+
+fn dupLuaField(L: *c.lua_State, idx: c_int, name: [:0]const u8) ![]u8 {
+ return c_allocator.dupe(u8, try luaFieldOrDefault(L, idx, name, ""));
+}
+
+fn luaFieldOrDefault(L: *c.lua_State, idx: c_int, name: [:0]const u8, default: []const u8) ![]const u8 {
+ const abs = c.lua_absindex(L, idx);
+ const t = c.lua_getfield(L, abs, name.ptr);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (t == T_NIL) return default;
+ if (t != T_STRING) return error.BadLuaStore;
+ var len: usize = 0;
+ const ptr = c.lua_tolstring(L, -1, &len) orelse return error.BadLuaStore;
+ return ptr[0..len];
+}
+
+fn intLuaField(L: *c.lua_State, idx: c_int, name: [:0]const u8) c.lua_Integer {
+ const abs = c.lua_absindex(L, idx);
+ const t = c.lua_getfield(L, abs, name.ptr);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (t != T_NUMBER) return 0;
+ return c.lua_tointegerx(L, -1, null);
+}
+
+fn fallbackNullSession() panto.Session {
+ var ns = panto.NullStore.init(c_allocator);
+ return ns.store().create();
+}
+
+fn pushPersistentMessage(L: *c.lua_State, pm: panto.PersistentMessage) void {
+ c.lua_createtable(L, 0, 4);
+ setStringField(L, "role", roleName(pm.message.role));
+ if (pm.usage) |u| pushUsage(L, u);
+ setStringField(L, "metadata", pm.message.metadata orelse "");
+ c.lua_createtable(L, @intCast(pm.message.content.items.len), 0);
+ for (pm.message.content.items, 0..) |block, i| {
+ pushInspectBlock(L, block);
+ c.lua_rawseti(L, -2, @intCast(i + 1));
+ }
+ c.lua_setfield(L, -2, "blocks");
+}
+
const JsonError = error{ OutOfMemory, BadHandlerReturn, InputNotJsonObject };
/// Parse JSON bytes (must be a top-level object — tool input convention)