summaryrefslogtreecommitdiff
path: root/src/lua_bridge.zig
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 /src/lua_bridge.zig
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 'src/lua_bridge.zig')
-rw-r--r--src/lua_bridge.zig322
1 files changed, 318 insertions, 4 deletions
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index 6395293..6bb318d 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -237,10 +237,43 @@ pub fn install(L: *c.lua_State) void {
// Default `emit`: a no-op until the runtime installs the real one.
c.lua_pushcclosure(L, emitNoopThunk, 0);
c.lua_setfield(L, -2, "emit");
+ // `panto.ext.json`: the JSON codec the platform already uses for tool
+ // inputs and schemas, exposed so extensions can decode event payloads
+ // (e.g. `tool_result.input`) and build `tool_call_complete.input`
+ // overrides without a rock dependency.
+ c.lua_createtable(L, 0, 2);
+ c.lua_pushcclosure(L, jsonDecodeThunk, 0);
+ c.lua_setfield(L, -2, "decode");
+ c.lua_pushcclosure(L, jsonEncodeThunk, 0);
+ c.lua_setfield(L, -2, "encode");
+ c.lua_setfield(L, -2, "json");
// Stash `ext` under its registry key, consuming it.
c.lua_rawsetp(L, LUA_REGISTRYINDEX, &ext_table_key);
}
+/// `panto.ext.json.decode(str)` -> Lua value. Accepts any top-level JSON
+/// value; JSON `null` decodes to Lua `nil` (documented lossiness for null
+/// object values).
+fn jsonDecodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ var len: usize = 0;
+ const ptr = c.luaL_checklstring(L, 1, &len);
+ var parsed = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, ptr[0..len], .{}) catch {
+ return c.luaL_error(L, "json.decode: invalid JSON");
+ };
+ defer parsed.deinit();
+ pushJsonValue(L, parsed.value) catch return c.luaL_error(L, "json.decode: out of memory");
+ return 1;
+}
+
+/// `panto.ext.json.encode(value)` -> JSON string. Same value domain as tool
+/// schemas: strings, numbers, booleans, nil, array- or object-shaped tables.
+fn jsonEncodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ pushValueAsJson(L, 1) catch return c.luaL_error(L, "json.encode: value is not JSON-serializable");
+ return 1;
+}
+
/// Override `panto.ext.emit` with a closure that carries `ctx` as a
/// light-userdata upvalue and dispatches into `emit_fn`. The runtime calls
/// this after `install` so a Lua `emit(name, data)` reaches the native
@@ -268,6 +301,285 @@ fn emitNoopThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
return 0;
}
+// ---------------------------------------------------------------------------
+// User-Lua entrypoints: "you are always in a panto-managed uv loop"
+// ---------------------------------------------------------------------------
+
+/// True while a panto-driven `uv.run` is executing on the shared state —
+/// set by the tool scheduler's batch driver and by `runEntrypoint`'s own
+/// pump. Consulted by nested dispatches (e.g. `panto.ext.emit` fired from
+/// inside already-running Lua): the loop is not re-entrant, so a nested
+/// entrypoint must complete without awaiting.
+pub var uv_loop_active: bool = false;
+
+/// Registry key for the memoized stash-wrapper function (see
+/// `pushStashWrapper`).
+var entry_wrapper_key: u8 = 0;
+/// Registry key for the result stash: a weak-keyed table mapping a
+/// coroutine thread -> `table.pack(...)` of its entrypoint's results.
+var entry_results_key: u8 = 0;
+
+/// Run a user-provided Lua entrypoint — an extension `activate()`, an
+/// `on` event handler, a slash-command handler, a component method —
+/// under the uniform contract: the code runs inside a coroutine, and the
+/// libuv loop is drained before returning. User Lua may therefore always
+/// await callback-form uv operations (arm, yield, resume-from-callback),
+/// exactly as in tool handlers, and any fire-and-forget coroutines it
+/// forks are also run to completion; no entrypoint needs to know whether
+/// a loop "is already there".
+///
+/// Expects the function and its `nargs` arguments on top of `L`;
+/// consumes them. Return values are discarded (use `runEntrypointResult`
+/// to keep one). Synchronous from the caller's view: returns only once
+/// the entrypoint has terminated, so registration-ordered handler chains
+/// and the component get/set pattern keep their sequencing.
+///
+/// On failure the error text (with traceback where available) is copied
+/// into `err_buf` when provided (`err_len.*` = bytes written, truncated)
+/// and logged otherwise.
+pub fn runEntrypoint(
+ L: *c.lua_State,
+ nargs: c_int,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ return runEntrypointInner(L, nargs, false, label, err_buf, err_len);
+}
+
+/// `runEntrypoint`, keeping the entrypoint's first return value: on
+/// success exactly one value (the result, or nil if it returned nothing)
+/// is left on top of `L`. Results are captured *inside* the coroutine (a
+/// wrapper stashes them keyed by thread), so they survive completion via
+/// a uv-callback resume — where a coroutine's return values flow to the
+/// resuming callback, not to us.
+pub fn runEntrypointResult(
+ L: *c.lua_State,
+ nargs: c_int,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ return runEntrypointInner(L, nargs, true, label, err_buf, err_len);
+}
+
+fn runEntrypointInner(
+ L: *c.lua_State,
+ nargs: c_int,
+ want_result: bool,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ const base = c.lua_gettop(L) - nargs - 1; // index below the fn
+ errdefer c.lua_settop(L, base);
+
+ // With a result requested, run `wrapper(fn, args...)` instead of
+ // `fn(args...)`; the wrapper stashes `table.pack(fn(...))` under the
+ // running thread before the coroutine terminates.
+ var total_args = nargs;
+ if (want_result) {
+ pushStashWrapper(L) catch {
+ reportEntrypointFailure(label, "failed to build the result wrapper", err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ c.lua_rotate(L, base + 1, 1); // wrapper beneath fn: wrapper, fn, args...
+ total_args += 1;
+ }
+
+ const co_ptr = c.lua_newthread(L) orelse {
+ reportEntrypointFailure(label, "out of memory creating coroutine", err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ const co: *c.lua_State = @ptrCast(co_ptr);
+ // Stack: [wrapper,] fn, args..., co. Rotate the thread to the bottom so
+ // it stays anchored on `L` (GC-safe) while the callable+args move over.
+ c.lua_rotate(L, base + 1, 1);
+ c.lua_xmove(L, co, total_args + 1);
+
+ var nres: c_int = 0;
+ const first = c.lua_resume(co, L, total_args, &nres);
+
+ if (uv_loop_active) {
+ // An outer frame is already driving the loop and uv.run is not
+ // re-entrant, so a nested entrypoint (e.g. one reached via `emit`
+ // from running Lua) cannot wait here. It must complete on its
+ // first resume; anything it armed is picked up by the outer drain.
+ if (first == c.LUA_YIELD) {
+ reportEntrypointFailure(
+ label,
+ "yielded during nested dispatch (the uv loop is already " ++
+ "running); an entrypoint invoked from running Lua " ++
+ "(e.g. via emit) must not await",
+ err_buf,
+ err_len,
+ );
+ return error.LuaRunFailed;
+ }
+ } else {
+ // Drain the loop completely — unconditionally, even when the
+ // first resume already finished. The entrypoint may have forked
+ // fire-and-forget coroutines whose armed callbacks would
+ // otherwise never run; nothing user Lua started may be left
+ // hanging when we return. (Corollary of the contract: an
+ // entrypoint must not leave long-lived handles armed — a
+ // repeating timer would hold this drain open forever, exactly as
+ // it would the tool batch's.)
+ drainLoop(L) catch {
+ // A handler error surfacing through a uv callback propagates
+ // out of uv.run; its message is on top of L.
+ reportErrorAtTop(L, label, err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ }
+
+ const status = c.lua_status(co);
+ if (status == c.LUA_YIELD) {
+ reportEntrypointFailure(
+ label,
+ "suspended after the uv loop drained: it yielded without a " ++
+ "pending libuv operation to resume it",
+ err_buf,
+ err_len,
+ );
+ return error.LuaRunFailed;
+ }
+ if (status != c.LUA_OK) {
+ // Error object on the coroutine's stack; render with traceback.
+ var mlen: usize = 0;
+ const mptr = c.lua_tolstring(co, -1, &mlen);
+ c.luaL_traceback(L, co, mptr, 0);
+ reportErrorAtTop(L, label, err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+
+ if (!want_result) {
+ c.lua_settop(L, base);
+ return;
+ }
+
+ // Fetch (and clear) this thread's stashed result pack.
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ if (c.lua_type(L, -1) != T_TABLE) {
+ reportEntrypointFailure(label, "no result was recorded", err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+ c.lua_pushvalue(L, base + 1); // the thread (anchored at base+1)
+ _ = c.lua_rawget(L, -2); // Stack: ..., stash, packed|nil
+ c.lua_pushvalue(L, base + 1);
+ c.lua_pushnil(L);
+ c.lua_rawset(L, -4); // stash[thread] = nil
+ if (c.lua_type(L, -1) != T_TABLE) {
+ reportEntrypointFailure(label, "no result was recorded", err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+ _ = c.lua_rawgeti(L, -1, 1); // first result (nil if it returned none)
+ // Stack: base..., co, stash, packed, result → keep only the result.
+ c.lua_rotate(L, base + 1, 1);
+ c.lua_settop(L, base + 1);
+}
+
+/// Push the memoized stash wrapper `function(fn, ...)` that runs `fn` and
+/// records `table.pack(fn(...))` in the result stash keyed by the running
+/// thread. The stash has weak keys so an abandoned thread's entry does
+/// not pin it.
+fn pushStashWrapper(L: *c.lua_State) !void {
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key);
+ if (c.lua_type(L, -1) == T_FUNCTION) return;
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil
+
+ // Ensure the stash table exists.
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ if (c.lua_type(L, -1) != T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil
+ c.lua_createtable(L, 0, 2);
+ c.lua_createtable(L, 0, 1); // its metatable
+ _ = c.lua_pushlstring(L, "k", 1);
+ c.lua_setfield(L, -2, "__mode"); // weak keys
+ _ = c.lua_setmetatable(L, -2);
+ c.lua_pushvalue(L, -1);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ }
+ // Stack: ..., stash. Compile the wrapper factory and apply it.
+ const chunk =
+ "local stash = ...\n" ++
+ "return function(fn, ...)\n" ++
+ " stash[coroutine.running()] = table.pack(fn(...))\n" ++
+ "end\n";
+ if (c.luaL_loadstring(L, chunk) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + stash
+ return error.LuaLoadFailed;
+ }
+ c.lua_rotate(L, -2, 1); // chunk beneath stash
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop error
+ return error.LuaRunFailed;
+ }
+ // Stack: ..., wrapper. Memoize and leave it on top.
+ c.lua_pushvalue(L, -1);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key);
+}
+
+/// `require("luv").run("default")`: run the loop until no active handles
+/// or requests remain, marking it panto-driven for the duration (nested
+/// entrypoints consult `uv_loop_active`). Returns immediately on an idle
+/// loop. When luv is not available (unit-test builds don't link it) there
+/// is no loop and nothing could have armed work on one — the drain is a
+/// no-op. On a `uv.run` error the Lua message is left on top of `L`.
+/// Shared by `runEntrypoint` and the tool scheduler's batch driver.
+pub fn drainLoop(L: *c.lua_State) !void {
+ uv_loop_active = true;
+ defer uv_loop_active = false;
+ _ = c.lua_getglobal(L, "require");
+ _ = c.lua_pushlstring(L, "luv", 3);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop the require error
+ return;
+ }
+ _ = c.lua_getfield(L, -1, "run");
+ c.lua_copy(L, -1, -2);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // Stack: ..., run
+ _ = c.lua_pushlstring(L, "default", 7);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) return error.UvRunFailed;
+ c.lua_settop(L, c.lua_gettop(L) - 1); // discard the boolean
+}
+
+/// Deliver an entrypoint failure: copy into the caller's buffer when one
+/// was provided, else log (warn under test — the test runner fails any
+/// test that logs at error level, and several tests exercise failures).
+fn reportEntrypointFailure(
+ label: []const u8,
+ msg: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) void {
+ if (err_buf) |buf| {
+ const n = @min(msg.len, buf.len);
+ @memcpy(buf[0..n], msg[0..n]);
+ if (err_len) |lp| lp.* = n;
+ return;
+ }
+ if (@import("builtin").is_test) {
+ std.log.warn("lua: '{s}' entrypoint failed: {s}", .{ label, msg });
+ } else {
+ std.log.err("lua: '{s}' entrypoint failed: {s}", .{ label, msg });
+ }
+}
+
+/// Like `reportEntrypointFailure`, with the message taken from the value
+/// at the top of `L` (not popped; the caller's stack cleanup handles it).
+fn reportErrorAtTop(
+ L: *c.lua_State,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) void {
+ var len: usize = 0;
+ const ptr = c.lua_tolstring(L, -1, &len);
+ const msg: []const u8 = if (ptr != null) ptr[0..len] else "(no error message)";
+ reportEntrypointFailure(label, msg, err_buf, err_len);
+}
+
/// Replace the registrations table with a fresh empty one. Used by the
/// long-lived runtime between loading distinct extension scripts so it
/// can harvest only the registrations made by the script just loaded
@@ -627,7 +939,7 @@ fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
expectField(L, 1, "handler", T_FUNCTION);
// Serialize the schema table to JSON, leaving the string on top.
- pushSchemaAsJson(L, 4) catch |err| {
+ pushValueAsJson(L, 4) catch |err| {
const msg = switch (err) {
BridgeError.OutOfMemory => "register_tool: out of memory serializing schema",
else => "register_tool: schema is not JSON-serializable",
@@ -768,9 +1080,11 @@ fn expectFieldNamed(
// JSON <-> Lua conversion
// ---------------------------------------------------------------------------
-/// Serialize the Lua table at stack index `idx` to a JSON string and push
-/// that string onto the stack.
-fn pushSchemaAsJson(L: *c.lua_State, idx: c_int) BridgeError!void {
+/// Serialize the Lua value at stack index `idx` to a JSON string and push
+/// that string onto the stack. Accepts strings, numbers, booleans, nil,
+/// and (array- or object-shaped) tables. Used for tool schemas at
+/// registration and for `tool_call_complete.input` event overrides.
+pub fn pushValueAsJson(L: *c.lua_State, idx: c_int) BridgeError!void {
var aw: std.Io.Writer.Allocating = .init(std.heap.c_allocator);
defer aw.deinit();