summaryrefslogtreecommitdiff
path: root/libpanto-lua/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-02 10:29:53 -0600
committert <t@tjp.lol>2026-07-03 12:10:09 -0600
commitc4d7f70ff831cfcdad0f2a6224f9a14f86905de6 (patch)
tree89dac8fb213dc6dcd2612a5ff6974d5013dcdb63 /libpanto-lua/src
parent800b2c4b6115845f6bf15d90484b3e80e81a606f (diff)
Stage and apply event-field overrides at tool lifecycle boundaries
Stage writable event-field overrides by tool call id so they can be applied at the correct point in the turn lifecycle. Capture input overrides from tool_call_complete, capture output overrides from tool_result, and clear staged overrides defensively at turn start and shutdown. Also honor user_message text overrides when starting a turn, add the helper that applies staged overrides to the agent at dispatch boundaries, and cover the behavior with tests for staged input/output overrides.
Diffstat (limited to 'libpanto-lua/src')
-rw-r--r--libpanto-lua/src/module.zig87
1 files changed, 86 insertions, 1 deletions
diff --git a/libpanto-lua/src/module.zig b/libpanto-lua/src/module.zig
index ecf89af..17d0fb2 100644
--- a/libpanto-lua/src/module.zig
+++ b/libpanto-lua/src/module.zig
@@ -85,6 +85,7 @@ pub const c = @cImport({
// canonical 5.4 values as clean `c_int`s.
const T_NIL: c_int = 0;
const T_BOOLEAN: c_int = 1;
+const T_LIGHTUSERDATA: c_int = 2;
const T_NUMBER: c_int = 3;
const T_STRING: c_int = 4;
const T_TABLE: c_int = 5;
@@ -179,10 +180,19 @@ const AgentBox = struct {
/// The lazily-created Lua tool source (null until first register_tool).
tools: ?*LuaToolSource,
closed: bool = false,
+ /// A borrowed box wraps an agent owned by the host embedder (see
+ /// `_wrap_agent`): teardown is the host's job, `config` is undefined,
+ /// and ownership-dependent methods (`set_config`, `register_tool`)
+ /// are refused.
+ borrowed: bool = false,
fn deinit(self: *AgentBox, L: *c.lua_State) void {
if (self.closed) return;
self.closed = true;
+ // Borrowed: the host owns the agent and everything it borrows;
+ // this box holds nothing else (empty arena, no store ref, no
+ // tools, no Io retain).
+ if (self.borrowed) return;
// Tear down the agent first (it borrows config + store + source).
self.agent.deinit();
if (self.tools) |ts| ts.deinit(L);
@@ -321,7 +331,7 @@ pub export fn luaopen_panto(L_opt: ?*c.lua_State) callconv(.c) c_int {
registerMetatables(L);
- c.lua_createtable(L, 0, 5);
+ c.lua_createtable(L, 0, 6);
c.lua_pushcclosure(L, agentNew, 0);
c.lua_setfield(L, -2, "agent");
c.lua_pushcclosure(L, conversationNew, 0);
@@ -330,6 +340,11 @@ pub export fn luaopen_panto(L_opt: ?*c.lua_State) callconv(.c) c_int {
c.lua_setfield(L, -2, "null_store");
c.lua_pushcclosure(L, fsJSONLStoreNew, 0);
c.lua_setfield(L, -2, "file_system_jsonl_store");
+ // Internal/unstable: wrap a host-owned `*panto.Agent` (lightuserdata)
+ // as a *borrowed* Agent userdata. Used by the panto CLI to expose its
+ // session agent to extensions (`panto.ext.agent`).
+ c.lua_pushcclosure(L, wrapAgent, 0);
+ c.lua_setfield(L, -2, "_wrap_agent");
_ = c.lua_pushstring(L, "libpanto-lua 0.0.0 (Lua 5.4)");
c.lua_setfield(L, -2, "_VERSION");
return 1;
@@ -479,6 +494,35 @@ fn buildAgent(L: *c.lua_State, box: *AgentBox) ConfigError!void {
}
}
+/// `panto._wrap_agent(ptr)` — wrap a host-owned `*panto.Agent`, passed as
+/// a lightuserdata, into a *borrowed* Agent userdata sharing the AGENT_MT
+/// method surface. The host retains ownership: `__gc` tears nothing down,
+/// and methods that would entangle box-owned state with the host's agent
+/// (`set_config`, `register_tool`) are refused.
+fn wrapAgent(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ if (c.lua_type(L, 1) != T_LIGHTUSERDATA)
+ return luaErr(L, "panto._wrap_agent: expected an agent pointer (lightuserdata)");
+ const ptr = c.lua_touserdata(L, 1) orelse
+ return luaErr(L, "panto._wrap_agent: null agent pointer");
+
+ const ud = c.lua_newuserdatauv(L, @sizeOf(AgentBox), 0) orelse
+ return luaErr(L, "panto._wrap_agent: out of memory");
+ const box: *AgentBox = @ptrCast(@alignCast(ud));
+ box.* = .{
+ .agent = @ptrCast(@alignCast(ptr)),
+ .config = undefined, // never read: set_config is refused on borrowed
+ .old_configs = .empty,
+ .store_ref = c.LUA_NOREF,
+ .string_arena = std.heap.ArenaAllocator.init(c_allocator),
+ .tools = null,
+ .closed = false,
+ .borrowed = true,
+ };
+ _ = c.luaL_setmetatable(L, AGENT_MT);
+ return 1;
+}
+
/// `agent:set_config{...}` — swap the active provider/model/policy. Takes
/// effect at the next turn boundary (libpanto re-reads the snapshot each
/// turn). The new config is parsed with the *same* parser as construction,
@@ -488,6 +532,7 @@ fn buildAgent(L: *c.lua_State, box: *AgentBox) ConfigError!void {
fn agentSetConfig(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const box = checkAgent(L, 1);
+ if (box.borrowed) return luaErr(L, "panto: set_config is not supported on a borrowed agent");
if (box.closed) return luaErr(L, "panto: agent is closed");
c.luaL_checktype(L, 2, T_TABLE);
@@ -1421,6 +1466,7 @@ fn agentRegisterTool(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const box = checkAgent(L, 1);
if (box.closed) return luaErr(L, "panto: agent is closed");
+ if (box.borrowed) return luaErr(L, "panto: register_tool is not supported on a borrowed agent (use panto.ext.register_tool)");
c.luaL_checktype(L, 2, T_TABLE);
if (box.tools == null) {
@@ -2483,6 +2529,45 @@ test "agent: set_config swaps without error; system prompt setters work" {
);
}
+test "_wrap_agent: borrowed agent shares the host's, and __gc leaves it alive" {
+ const L = try newTestState();
+ defer c.lua_close(L);
+ try runScript(L,
+ \\a = panto.agent { api_style="openai_chat", api_key="k", base_url="http://127.0.0.1:1", model="m" }
+ );
+ // Recover the underlying `*panto.Agent`, as a host embedder would
+ // hold it, and wrap it borrowed.
+ _ = c.lua_getglobal(L, "a");
+ const owner = checkAgent(L, -1);
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ _ = c.lua_getglobal(L, "panto");
+ _ = c.lua_getfield(L, -1, "_wrap_agent");
+ c.lua_pushlightuserdata(L, owner.agent);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) return error.WrapFailed;
+ c.lua_setglobal(L, "b");
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop panto
+
+ try runScript(L,
+ \\-- Writes through the borrowed wrapper land on the shared agent.
+ \\b:add_system_message("from borrowed")
+ \\assert(a:conversation():len() == 1)
+ \\-- Ownership-dependent methods are refused.
+ \\local ok, err = pcall(function()
+ \\ b:set_config { api_style="openai_chat", api_key="k", base_url="http://127.0.0.1:1", model="m" }
+ \\end)
+ \\assert(not ok and err:find("borrowed"), tostring(err))
+ \\ok, err = pcall(function()
+ \\ b:register_tool { name="t", description="d", schema={type="object"}, handler=function() end }
+ \\end)
+ \\assert(not ok and err:find("borrowed"), tostring(err))
+ \\-- Collecting the borrowed wrapper must not tear the agent down.
+ \\b = nil
+ \\collectgarbage("collect")
+ \\a:add_system_message("still alive")
+ \\assert(a:conversation():len() == 2)
+ );
+}
+
test "register_tool without luv fails loud at registration" {
// The test binary links Lua but NOT luv, so require('luv') fails. We
// assert that surfaces as a clear error at register time, not later.