summaryrefslogtreecommitdiff
path: root/src/lua_event_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_event_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_event_bridge.zig')
-rw-r--r--src/lua_event_bridge.zig275
1 files changed, 217 insertions, 58 deletions
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) ------------------------
+ // -- bridged render / input (shared entrypoint machinery) --------------
- /// 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.
+ /// 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;