From c4d7f70ff831cfcdad0f2a6224f9a14f86905de6 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 2 Jul 2026 10:29:53 -0600 Subject: 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. --- src/lua_event_bridge.zig | 277 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 218 insertions(+), 59 deletions(-) (limited to 'src/lua_event_bridge.zig') diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig index 044dd55..06a5ddf 100644 --- a/src/lua_event_bridge.zig +++ b/src/lua_event_bridge.zig @@ -10,16 +10,18 @@ //! It is owned by the long-lived `LuaRuntime` (one `lua_State` for the //! whole session) and shares that state. It never opens its own state. //! -//! ## Synchronous rendering (NOT the coroutine scheduler) +//! ## Rendering runs through the shared entrypoint machinery //! //! A Lua-defined component's `render(width)` is on the engine's hot path: //! the differential renderer calls it and needs the produced lines back -//! IMMEDIATELY. So the bridged vtable calls into Lua **synchronously** via -//! `lua_pcallk` (the same model as `LuaRuntime.runCommand`), NOT through -//! the libuv coroutine scheduler used for async tool batches. A render -//! callback may not yield; if a Lua component blocks, that is a bug in the -//! extension, and we surface a safe fallback line rather than hanging or -//! corrupting the frame. +//! before the frame can complete. Like every user-Lua entrypoint it runs +//! via `lua_bridge.runEntrypointResult` — inside a coroutine, with the uv +//! loop drained before returning — so a renderer *may* await libuv work, +//! at the cost of putting that I/O latency inside the paint. The call is +//! still synchronous from the engine's view. On any Lua error (including +//! yielding inside a nested dispatch, where the loop cannot be driven) we +//! surface a safe fallback line rather than hanging or corrupting the +//! frame. //! //! ## Cache-derived `firstLineChanged` //! @@ -216,15 +218,19 @@ pub const EventBridge = struct { // -- bridged component construction ------------------------------------ /// Build a native `Component` backed by the Lua component table at - /// stack index `table_idx`. `luaL_ref`s the table, allocates a tracked - /// `BridgedComponent`, and returns its `comp()`. The component lives - /// until `invalidate` (which frees it) or runtime teardown. + /// stack index `table_idx` of `L` — the *calling* state, which since + /// entrypoints run as coroutines is usually a handler's thread, not + /// `self.L`. The registry is shared across threads, so the ref taken + /// here stays usable from `self.L` for rendering and teardown. + /// Allocates a tracked `BridgedComponent` and returns its `comp()`; + /// the component lives until `invalidate` (which frees it) or runtime + /// teardown. /// /// No "active component": each call mints a fresh instance. - fn makeBridgedComponent(self: *EventBridge, table_idx: c_int) !Component { - c.lua_pushvalue(self.L, table_idx); - const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX); - errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref); + fn makeBridgedComponent(self: *EventBridge, L: *c.lua_State, table_idx: c_int) !Component { + c.lua_pushvalue(L, table_idx); + const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); + errdefer c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref); const bc = try self.alloc.create(BridgedComponent); errdefer self.alloc.destroy(bc); @@ -285,15 +291,19 @@ pub const EventBridge = struct { self.releaseOverride(old); } - // -- bridged render / input (SYNCHRONOUS pcall) ------------------------ - - /// Render a Lua-defined component synchronously: push its table + - /// `render` method + width, `lua_pcallk`, marshal the returned - /// array-of-strings into the component's `RenderCache`, and return the - /// cached lines. On ANY Lua error or bad return, return a single dim - /// error line instead of crashing the render loop (the frame must - /// always complete). Every returned line is truncated to `width` so - /// the engine's width contract (§3.1) is never violated. + // -- bridged render / input (shared entrypoint machinery) -------------- + + /// Render a Lua-defined component: push its table + `render` method + + /// width, run it as a user-Lua entrypoint (`runEntrypointResult` — + /// coroutine + loop drain, so a renderer may await uv work like any + /// other user Lua), marshal the returned array-of-strings into the + /// component's `RenderCache`, and return the cached lines. The call is + /// synchronous from the engine's view — the frame waits for the lines + /// (an awaiting renderer puts its I/O latency inside that paint). On + /// ANY Lua error or bad return, return a single dim error line instead + /// of crashing the render loop (the frame must always complete). Every + /// returned line is truncated to `width` so the engine's width + /// contract (§3.1) is never violated. fn renderBridged(self: *EventBridge, bc: *BridgedComponent, width: usize) anyerror![]const []const u8 { const L = self.L; if (bc.dead) return &.{}; @@ -308,12 +318,14 @@ pub const EventBridge = struct { if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { return self.fallbackLines(bc, width, "component has no render()"); } - // Stack: ..., table, render. Call render(table, width) -> lines. + // Stack: ..., table, render. Run render(table, width) -> lines. c.lua_pushvalue(L, -2); // self (the table) c.lua_pushinteger(L, @intCast(width)); - if (c.lua_pcallk(L, 2, 1, 0, 0, null) != 0) { - return self.fallbackLines(bc, width, luaErrText(L)); - } + var err_buf: [256]u8 = undefined; + var err_len: usize = 0; + lua_bridge.runEntrypointResult(L, 2, "component render()", &err_buf, &err_len) catch { + return self.fallbackLines(bc, width, err_buf[0..err_len]); + }; // Stack top: the returned lines table. const raw = lua_bridge.readLinesArray(L, -1, self.alloc) catch { return self.fallbackLines(bc, width, "render() must return an array of strings"); @@ -363,7 +375,8 @@ pub const EventBridge = struct { } /// Feed input to a Lua component's optional `handleInput(self, data)` - /// method, synchronously. Errors are swallowed (input handling must + /// method, run as a user-Lua entrypoint (coroutine + loop drain, like + /// every other user Lua). Errors are swallowed (input handling must /// never crash the loop); a component without the method ignores input. fn handleInputBridged(self: *EventBridge, bc: *BridgedComponent, data: []const u8) void { const L = self.L; @@ -377,8 +390,8 @@ pub const EventBridge = struct { if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) return; c.lua_pushvalue(L, -2); // self _ = c.lua_pushlstring(L, data.ptr, data.len); - // Any error: ignore (best-effort input). - _ = c.lua_pcallk(L, 2, 0, 0, 0, null); + // Any error: already reported by the helper; best-effort input. + lua_bridge.runEntrypoint(L, 2, "component handleInput()", null, null) catch {}; // Mark the cache dirty after input. The engine reads // `firstLineChanged()` BEFORE calling `render`, so a clean cache means @@ -399,11 +412,15 @@ pub const EventBridge = struct { // =========================================================================== /// The native `Handler.callback` for a bridged Lua handler. Builds the -/// `event` userdata, calls the Lua function with it (synchronously, under -/// a traceback errfunc), and lets the Lua side read/replace the component -/// via `event:get_component()` / `event:set_component()`. Errors in the Lua -/// handler are logged and swallowed — a broken handler must not abort the -/// event dispatch or the render loop. +/// `event` userdata and runs the Lua function with it as a user-Lua +/// entrypoint — inside a coroutine, with the uv loop driven until the +/// handler completes — so handlers may await libuv work exactly like +/// tool handlers. The call is synchronous from the dispatcher's view: +/// it returns only once the handler has terminated, preserving +/// registration-ordered chaining and the `get_component()` → wrap → +/// `set_component()` pattern (`active_event` stays pinned for the whole +/// run). Errors in the Lua handler are logged and swallowed — a broken +/// handler must not abort the event dispatch or the render loop. fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void { const h: *LuaHandler = @ptrCast(@alignCast(ctx)); const self = h.bridge; @@ -417,25 +434,13 @@ fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void { const base = c.lua_gettop(L); defer c.lua_settop(L, base); - // Traceback errfunc beneath the call. - _ = c.lua_getglobal(L, "debug"); - _ = c.lua_getfield(L, -1, "traceback"); - c.lua_copy(L, -1, -2); - c.lua_settop(L, c.lua_gettop(L) - 1); // pop `debug` table - const errfunc_idx = c.lua_gettop(L); - _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(h.fn_ref)); pushEventObject(self) catch { c.lua_settop(L, base); return; }; - if (c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null) != 0) { - var len: usize = 0; - const msg = c.lua_tolstring(L, -1, &len); - if (msg != null) { - std.log.err("lua event handler '{s}' failed: {s}", .{ h.event_name, msg[0..len] }); - } - } + // Failure is reported (with traceback) inside runEntrypoint. + lua_bridge.runEntrypoint(L, 1, h.event_name, null, null) catch {}; } // =========================================================================== @@ -463,7 +468,64 @@ fn ensureEventMetatable(L: *c.lua_State) void { if (c.luaL_newmetatable(L, eventMtName) != 0) { c.lua_pushcclosure(L, eventIndexThunk, 0); c.lua_setfield(L, -2, "__index"); + c.lua_pushcclosure(L, eventNewindexThunk, 0); + c.lua_setfield(L, -2, "__newindex"); + } +} + +/// Raise a Lua error with a formatted message (never returns normally). +fn luaErrFmt(L: *c.lua_State, comptime fmt: []const u8, args: anytype) c_int { + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, fmt, args) catch fmt; + _ = c.lua_pushlstring(L, msg.ptr, msg.len); + return c.lua_error(L); +} + +/// `__newindex(event_ud, key, value)`: the "override via mutable attribute" +/// paradigm. Assigning to an event's writable payload field (see +/// `tui_event.writableField` — e.g. `ev.output = ...` on `tool_result`) +/// records an override on the bus. Later handlers in the same dispatch read +/// the overridden value (chaining, last writer wins), and the firer applies +/// it to the real underlying state after dispatch. Any other assignment is +/// an error — events are otherwise read-only. +/// +/// `tool_call_complete.input` is assigned as a JSON-able table (tool inputs +/// are JSON objects on the wire) and serialized here, so a handler gets an +/// immediate error for unserializable values; `tool_result.output` and +/// `user_message.text` are plain strings. +fn eventNewindexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { + const L = L_opt.?; + const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0)); + const bridge = ud.*; + var klen: usize = 0; + const kptr = c.luaL_checklstring(L, 2, &klen); + const key = kptr[0..klen]; + + const ev = bridge.active_event orelse + return luaErrFmt(L, "event fields can only be assigned inside a handler", .{}); + const writable = ui_event.writableField(ev.name) orelse + return luaErrFmt(L, "event '{s}' has no writable fields", .{ev.name}); + if (!std.mem.eql(u8, key, writable)) + return luaErrFmt(L, "field '{s}' of event '{s}' is not writable (only '{s}')", .{ key, ev.name, writable }); + const bus = bridge.bus orelse + return luaErrFmt(L, "event '{s}': no event bus attached", .{ev.name}); + + if (std.mem.eql(u8, ev.name, "tool_call_complete")) { + if (c.lua_type(L, 3) != lua_bridge.T_TABLE) + return luaErrFmt(L, "'input' override must be a table (tool input is a JSON object)", .{}); + lua_bridge.pushValueAsJson(L, 3) catch + return luaErrFmt(L, "'input' override is not JSON-serializable", .{}); + } else { + if (c.lua_type(L, 3) != lua_bridge.T_STRING) + return luaErrFmt(L, "'{s}' override must be a string", .{writable}); + c.lua_pushvalue(L, 3); } + var vlen: usize = 0; + const vptr = c.lua_tolstring(L, -1, &vlen); + bus.setOverride(vptr[0..vlen]) catch + return luaErrFmt(L, "out of memory recording event override", .{}); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop the value copy / JSON string + return 0; } /// `__index(event_ud, key)`: dispatch method names and payload fields. @@ -493,6 +555,19 @@ fn eventIndexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { _ = c.lua_pushlstring(L, ev.name.ptr, ev.name.len); return 1; } + // A pending override shadows the raw payload field, so handlers later + // in the dispatch chain read what earlier ones wrote (§ writable + // event fields). + if (bridge.bus) |bus| { + if (bus.override_value) |ov| { + if (ui_event.writableField(ev.name)) |wf| { + if (std.mem.eql(u8, key, wf)) { + _ = c.lua_pushlstring(L, ov.ptr, ov.len); + return 1; + } + } + } + } pushPayloadField(L, ev, key); return 1; } @@ -625,7 +700,7 @@ fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { return c.luaL_error(L, "set_component: unknown userdata (expected a component)"); } if (ty == lua_bridge.T_TABLE) { - const comp = bridge.makeBridgedComponent(2) catch { + const comp = bridge.makeBridgedComponent(L, 2) catch { return c.luaL_error(L, "set_component: failed to bridge Lua component"); }; ev.setComponent(comp); @@ -644,19 +719,42 @@ fn pushNativeComponent(L: *c.lua_State, comp: Component) void { } fn ensureNativeCompMetatable(L: *c.lua_State) void { - // A bare named metatable (no methods); identity-only so - // `luaL_testudata` can recognize the passthrough handle. - _ = c.luaL_newmetatable(L, nativeCompMtName); + if (c.luaL_newmetatable(L, nativeCompMtName) != 0) { + // One method: `handle:render(width) -> { line, ... }`, so a Lua + // wrapper component can render *through* the native default it + // wrapped (the §7.5 wrap pattern) and append its own lines below. + c.lua_createtable(L, 0, 1); + c.lua_pushcclosure(L, nativeCompRenderThunk, 0); + c.lua_setfield(L, -2, "render"); + c.lua_setfield(L, -2, "__index"); + } } -/// Read the top-of-stack Lua error as text (after a failed pcall). -fn luaErrText(L: *c.lua_State) []const u8 { - var len: usize = 0; - const ptr = c.lua_tolstring(L, -1, &len); - if (ptr == null) return "lua error"; - return ptr[0..len]; +/// `handle:render(width)`: invoke the wrapped native component's vtable +/// render and return its lines as a Lua array of strings. The lines are +/// copied onto the Lua stack, so the native component's cache ownership is +/// untouched. Errors surface as Lua errors (a wrapper's render already runs +/// under the entrypoint machinery, which reports and falls back safely). +fn nativeCompRenderThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { + const L = L_opt.?; + const ud: *Component = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, nativeCompMtName) orelse return 0)); + const width_i = c.luaL_checkinteger(L, 2); + if (width_i < 0) return c.luaL_error(L, "render: width must be non-negative"); + const width: usize = @intCast(width_i); + // Native components render from their own caches; the allocator + // parameter is unused by them (mirrors the engine's call shape). + const lines = ud.vtable.render(ud.ptr, width, std.heap.c_allocator) catch { + return c.luaL_error(L, "render: native component render failed"); + }; + c.lua_createtable(L, @intCast(lines.len), 0); + for (lines, 1..) |line, i| { + _ = c.lua_pushlstring(L, line.ptr, line.len); + c.lua_rawseti(L, -2, @intCast(i)); + } + return 1; } +/// Read the top-of-stack Lua error as text (after a failed pcall). /// Re-type the cache's owned lines as `[]const []const u8` for the vtable /// return (mirrors the native components' `cacheLines`). fn cacheLines(cache: *RenderCache) []const []const u8 { @@ -1463,6 +1561,67 @@ test "lifecycle payload fields marshal to Lua: tool id/delta/input/output, think ); } +test "writable event fields: overrides chain across handlers, land on the bus, and reject bad writes" { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + lua_bridge.install(L); + lua_bridge.installTestPantoTable(L); + + var bridge = EventBridge.init(testing.allocator, L); + defer bridge.deinit(); + var bus = EventBus.init(testing.allocator); + defer bus.deinit(); + + try runScript(L, + \\seen = {} + \\panto.ext.on("tool_result", function(e) + \\ e.output = e.output .. " [extra]" + \\end) + \\panto.ext.on("tool_result", function(e) + \\ -- Chaining: this handler must read the first handler's write. + \\ seen.chained = e.output + \\ -- Read-only fields still reject assignment. + \\ local ok, err = pcall(function() e.id = "nope" end) + \\ seen.readonly_err = not ok and err or nil + \\end) + \\panto.ext.on("tool_call_complete", function(e) + \\ -- input is assigned as a table and serialized to JSON. + \\ local ok = pcall(function() e.input = "raw string" end) + \\ seen.input_string_rejected = not ok + \\ e.input = { path = "redirected.txt" } + \\ seen.input_after = e.input + \\end) + \\panto.ext.on("session_start", function(e) + \\ local ok, err = pcall(function() e.cwd = "/elsewhere" end) + \\ seen.observe_only_err = not ok and err or nil + \\end) + ); + try harvestOnInto(&bridge); + try bridge.attachBus(&bus); + + _ = bus.fire("tool_result", null, .{ .tool = .{ .id = "c1", .output = "result" } }); + const out = bus.takeOverride() orelse return error.NoOverride; + defer testing.allocator.free(out); + try testing.expectEqualStrings("result [extra]", out); + + _ = bus.fire("tool_call_complete", null, .{ .tool = .{ .id = "c1", .input = "{\"path\":\"orig.txt\"}" } }); + const inp = bus.takeOverride() orelse return error.NoOverride; + defer testing.allocator.free(inp); + try testing.expectEqualStrings("{\"path\":\"redirected.txt\"}", inp); + + _ = bus.fire("session_start", null, .{ .session_start = .{ .version = "v", .cwd = "/", .model = "m" } }); + try testing.expect(bus.takeOverride() == null); + + try runScript(L, + \\assert(seen.chained == "result [extra]", "second handler sees first's write") + \\assert(seen.readonly_err and seen.readonly_err:find("not writable"), "read-only field rejects") + \\assert(seen.input_string_rejected, "input override must be a table") + \\assert(seen.input_after == '{"path":"redirected.txt"}', "chained input read is the JSON") + \\assert(seen.observe_only_err and seen.observe_only_err:find("no writable fields"), "observe-only event rejects") + ); +} + test "lifecycle handlers fire for every new event name" { // Confirm dispatch is purely by event-name string: a handler on each new // lifecycle event name actually runs. No new dispatch machinery exists; -- cgit v1.3