From e2477f7d2d2a4eab870a2bd61534cfd146907915 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 11 Jun 2026 09:08:42 -0600 Subject: libpanto API fixes - FSJSONLStore: don't require `cwd`, instead panto CLI provides it in the session metadata - Lua: expose the `SessionStore` interface and the `FSJSONLStore` and `NullStore` implementations - C: use idiomatic `const char *` for incoming strings - C: expose the `FSJSONLStore` and `NullStore` implementations --- libpanto-lua/src/module.zig | 368 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 362 insertions(+), 6 deletions(-) (limited to 'libpanto-lua/src') 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) -- cgit v1.3