diff options
| author | t <t@tjp.lol> | 2026-07-02 10:29:53 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-03 12:10:09 -0600 |
| commit | c4d7f70ff831cfcdad0f2a6224f9a14f86905de6 (patch) | |
| tree | 89dac8fb213dc6dcd2612a5ff6974d5013dcdb63 /src/lua_bridge.zig | |
| parent | 800b2c4b6115845f6bf15d90484b3e80e81a606f (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.zig | 322 |
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(); |
