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 | |
| 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.
| -rw-r--r-- | libpanto-lua/src/module.zig | 87 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 116 | ||||
| -rw-r--r-- | libpanto/src/openai_responses_json.zig | 97 | ||||
| -rw-r--r-- | src/extension_loader.zig | 14 | ||||
| -rw-r--r-- | src/lua_bridge.zig | 322 | ||||
| -rw-r--r-- | src/lua_event_bridge.zig | 275 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 220 | ||||
| -rw-r--r-- | src/main.zig | 4 | ||||
| -rw-r--r-- | src/tui_app.zig | 174 | ||||
| -rw-r--r-- | src/tui_event.zig | 50 |
10 files changed, 1157 insertions, 202 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. diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index faf0a4a..3616ab6 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -374,6 +374,68 @@ pub const Agent = struct { self.flushPersist(); } + /// Replace the text of the ToolResult block matching `tool_use_id` in + /// the conversation's LAST message. Media parts are preserved (the new + /// text becomes the single leading text part); `is_error` is untouched. + /// Returns false when the last message holds no such block. + /// + /// This is the application half of the embedder's "tool_result output + /// override" (a `tool_result` event handler assigning `ev.output`): + /// called between tool dispatch and the next provider request, the + /// override becomes canonical — wire, persistence, and compaction all + /// see the replaced text. + pub fn overrideToolResultOutput(self: *Agent, tool_use_id: []const u8, text: []const u8) !bool { + const msgs = self.conversation.messages.items; + if (msgs.len == 0) return false; + const last = &msgs[msgs.len - 1]; + if (last.role != .user) return false; + for (last.content.items) |*block| { + if (block.* != .ToolResult) continue; + const tr = &block.ToolResult; + if (!std.mem.eql(u8, tr.tool_use_id, tool_use_id)) continue; + + var new_parts: std.ArrayList(conversation.ResultPartStored) = .empty; + errdefer new_parts.deinit(self._allocator); + try new_parts.ensureTotalCapacity(self._allocator, tr.parts.items.len + 1); + var new_text: conversation.TextualBlock = .empty; + try new_text.appendSlice(self._allocator, text); + errdefer new_text.deinit(self._allocator); + new_parts.appendAssumeCapacity(.{ .text = new_text }); + for (tr.parts.items) |*p| switch (p.*) { + .text => |*t| t.deinit(self._allocator), + .media => |m| new_parts.appendAssumeCapacity(.{ .media = m }), + }; + tr.parts.deinit(self._allocator); + tr.parts = new_parts; + return true; + } + return false; + } + + /// Replace the input JSON of the ToolUse block matching `tool_use_id` + /// in the conversation's LAST message (the committed assistant message, + /// between response completion and tool dispatch). Returns false when + /// no such block exists there. + /// + /// Application half of the "tool_call_complete input override": the + /// rewritten input is what the tool executes with AND what the model + /// sees of its own call in future requests. + pub fn overrideToolUseInput(self: *Agent, tool_use_id: []const u8, input_json: []const u8) !bool { + const msgs = self.conversation.messages.items; + if (msgs.len == 0) return false; + const last = &msgs[msgs.len - 1]; + if (last.role != .assistant) return false; + for (last.content.items) |*block| { + if (block.* != .ToolUse) continue; + const tu = &block.ToolUse; + if (!std.mem.eql(u8, tu.id, tool_use_id)) continue; + tu.input.clearRetainingCapacity(); + try tu.input.appendSlice(self._allocator, input_json); + return true; + } + return false; + } + /// Submit a user message and begin a turn, returning a resumable pull /// `Stream`. /// @@ -2216,6 +2278,60 @@ test "agent runs a turn against NullStore without persisting or erroring" { try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); } +test "override mutators rewrite the conversation tail's tool blocks" { + const allocator = testing.allocator; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + var ns = null_store_mod.NullStore.init(allocator); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); + defer agent.deinit(); + + const conv = &agent.conversation; + // Committed assistant message carrying one ToolUse. + var a_content: std.ArrayList(conversation.ContentBlock) = .empty; + try a_content.append(allocator, .{ .ToolUse = .{ + .id = try allocator.dupe(u8, "tc_1"), + .name = try allocator.dupe(u8, "read"), + .input = try conversation.textualBlockFromSlice(allocator, "{\"path\":\"orig\"}"), + } }); + try conv.messages.append(allocator, .{ .role = .assistant, .content = a_content }); + + // Input override rewrites the tail's matching ToolUse; unknown ids miss. + try testing.expect(try agent.overrideToolUseInput("tc_1", "{\"path\":\"new\"}")); + try testing.expect(!try agent.overrideToolUseInput("tc_missing", "{}")); + const tu = conv.messages.items[0].content.items[0].ToolUse; + try testing.expectEqualStrings("{\"path\":\"new\"}", tu.input.items); + + // Tool-result user message: a text part followed by a media part. + var parts: std.ArrayList(conversation.ResultPartStored) = .empty; + try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "original output") }); + try parts.append(allocator, .{ .media = .{ + .media_type = try allocator.dupe(u8, "image/png"), + .data = try conversation.textualBlockFromSlice(allocator, "aW1n"), + } }); + var u_content: std.ArrayList(conversation.ContentBlock) = .empty; + try u_content.append(allocator, .{ .ToolResult = .{ + .tool_use_id = try allocator.dupe(u8, "tc_1"), + .parts = parts, + } }); + try conv.messages.append(allocator, .{ .role = .user, .content = u_content }); + + // The ToolUse override only targets the tail — now a user message. + try testing.expect(!try agent.overrideToolUseInput("tc_1", "{}")); + + // Output override replaces the text, keeps the media part. + try testing.expect(try agent.overrideToolResultOutput("tc_1", "replaced output")); + try testing.expect(!try agent.overrideToolResultOutput("tc_other", "x")); + const tr = conv.messages.items[1].content.items[0].ToolResult; + try testing.expectEqual(@as(usize, 2), tr.parts.items.len); + try testing.expectEqualStrings("replaced output", tr.parts.items[0].text.items); + try testing.expectEqualStrings("image/png", tr.parts.items[1].media.media_type); +} + /// A configurable ToolSource for testing the grouped-dispatch path. /// Stores every batch it receives so tests can assert "calls X and Y /// arrived in the same batch on the same thread". diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig index cb58c2c..358fc45 100644 --- a/libpanto/src/openai_responses_json.zig +++ b/libpanto/src/openai_responses_json.zig @@ -169,9 +169,14 @@ fn writeInputForMessage( if (b == .ToolResult) has_tool_result = true; } if (has_tool_result) { + // `function_call_output` is text-only. Each result carries + // its text (plus a short note when media is present); the + // media rides along afterward in a synthetic user message. + var any_media = false; for (msg.content.items) |block| { if (block != .ToolResult) continue; const tr = block.ToolResult; + if (tr.hasMedia()) any_media = true; try s.beginObject(); try s.objectField("type"); try s.write("function_call_output"); @@ -181,9 +186,52 @@ fn writeInputForMessage( var tbuf: std.ArrayList(u8) = .empty; defer tbuf.deinit(allocator); try tr.appendTextInto(allocator, &tbuf); + if (tr.hasMedia()) { + if (tbuf.items.len > 0) try tbuf.append(allocator, '\n'); + try tbuf.appendSlice(allocator, "[attachment(s) provided in the following user message]"); + } try s.write(tbuf.items); try s.endObject(); } + // Synthetic user message holding the media: images as + // `input_image` data URLs, PDFs as `input_file` (the + // Responses API rejects non-image data URLs in + // `input_image`). + if (any_media) { + try s.beginObject(); + try s.objectField("role"); + try s.write("user"); + try s.objectField("content"); + try s.beginArray(); + for (msg.content.items) |block| { + if (block != .ToolResult) continue; + for (block.ToolResult.parts.items) |part| { + if (part != .media) continue; + const m = part.media; + var url_buf: std.ArrayList(u8) = .empty; + defer url_buf.deinit(allocator); + try url_buf.appendSlice(allocator, "data:"); + try url_buf.appendSlice(allocator, m.media_type); + try url_buf.appendSlice(allocator, ";base64,"); + try url_buf.appendSlice(allocator, m.data.items); + const is_pdf = std.mem.eql(u8, m.media_type, "application/pdf"); + try s.beginObject(); + try s.objectField("type"); + try s.write(if (is_pdf) "input_file" else "input_image"); + if (is_pdf) { + try s.objectField("filename"); + try s.write("attachment.pdf"); + try s.objectField("file_data"); + } else { + try s.objectField("image_url"); + } + try s.write(url_buf.items); + try s.endObject(); + } + } + try s.endArray(); + try s.endObject(); + } } // Plain user text (skip if the message was purely tool results). var text_buf: std.ArrayList(u8) = .empty; @@ -714,6 +762,55 @@ test "responses serializeRequest - assistant tool_use + tool result round-trip" try testing.expectEqualStrings("42", input[2].object.get("output").?.string); } +test "responses serializeRequest - tool result media splits into output note + synthetic user" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + const rid = try allocator.dupe(u8, "call_img"); + var parts: std.ArrayList(conversation.ResultPartStored) = .empty; + try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "the file:") }); + try parts.append(allocator, .{ .media = .{ + .media_type = try allocator.dupe(u8, "image/png"), + .data = try conversation.textualBlockFromSlice(allocator, "iVBOR=="), + } }); + try parts.append(allocator, .{ .media = .{ + .media_type = try allocator.dupe(u8, "application/pdf"), + .data = try conversation.textualBlockFromSlice(allocator, "JVBERg=="), + } }); + var content: std.ArrayList(conversation.ContentBlock) = .empty; + try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = rid, .parts = parts } }); + try conv.messages.append(allocator, .{ .role = .user, .content = content }); + + var tools = emptyTools(); + defer tools.deinit(); + const cfg = testConfig("gpt-5.1-codex"); + const body = try serializeRequest(allocator, &cfg, &conv, &tools, .public); + defer allocator.free(body); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const input = parsed.value.object.get("input").?.array.items; + // function_call_output + synthetic user message = 2. + try testing.expectEqual(@as(usize, 2), input.len); + + // The output carries the text plus the attachment note. + try testing.expectEqualStrings("function_call_output", input[0].object.get("type").?.string); + const out_text = input[0].object.get("output").?.string; + try testing.expect(std.mem.indexOf(u8, out_text, "the file:") != null); + try testing.expect(std.mem.indexOf(u8, out_text, "[attachment(s) provided in the following user message]") != null); + + // The synthetic user message carries the image and the PDF. + try testing.expectEqualStrings("user", input[1].object.get("role").?.string); + const uc = input[1].object.get("content").?.array.items; + try testing.expectEqual(@as(usize, 2), uc.len); + try testing.expectEqualStrings("input_image", uc[0].object.get("type").?.string); + try testing.expectEqualStrings("data:image/png;base64,iVBOR==", uc[0].object.get("image_url").?.string); + try testing.expectEqualStrings("input_file", uc[1].object.get("type").?.string); + try testing.expectEqualStrings("attachment.pdf", uc[1].object.get("filename").?.string); + try testing.expectEqualStrings("data:application/pdf;base64,JVBERg==", uc[1].object.get("file_data").?.string); +} + test "responses parseStreamEvent - output_text delta" { const allocator = testing.allocator; var ev = try parseStreamEvent(allocator, diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 4620a2e..1c29ceb 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -220,7 +220,11 @@ pub fn loadFromDirs( for (found.items) |f| { var entries: std.array_list.Managed(Entry) = .init(allocator); defer entries.deinit(); - try runtime.evalEntries(f.script_path, f.package_root, &entries); + runtime.evalEntries(f.script_path, f.package_root, &entries) catch |err| { + // Entries collected before the failure hold Lua refs + names. + for (entries.items) |e| runtime.dropEntry(e); + return err; + }; for (entries.items) |e| { try cands.append(.{ .entry = e, .source = f.source, .script_path = f.script_path }); } @@ -254,10 +258,13 @@ pub fn loadFromDirs( runtime.dropEntry(cnd.entry); continue; } + // Log before activating: `activateEntry` consumes the entry + // (frees its name), so `cnd.entry.name` is dangling afterwards. + std.log.debug("extension: activating '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() }); runtime.activateEntry(cnd.entry) catch |err| { logConflict( - "extension '{s}' ({s}: {s}) failed to activate: {t}", - .{ cnd.entry.name, cnd.source.label(), cnd.script_path, err }, + "extension ({s}: {s}) failed to activate: {t}", + .{ cnd.source.label(), cnd.script_path, err }, ); // activateEntry consumed the entry already. Drop the rest. var j = i + 1; @@ -265,7 +272,6 @@ pub fn loadFromDirs( cands.clearRetainingCapacity(); return err; }; - std.log.debug("extension: activated '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() }); } cands.clearRetainingCapacity(); 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(); 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; diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 6b3a2f9..e2b7ec8 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -84,10 +84,6 @@ pub const LuaRuntime = struct { /// `panto._record_result`. Allocated once at `create`; reused for /// every call. `0` until `installScheduler` runs. wrapper_ref: c_int = 0, - /// Registry ref to `require("luv").run`, the function the scheduler - /// calls to drive libuv to completion. `0` until - /// `installScheduler` runs. - uv_run_ref: c_int = 0, /// Registry ref to `require("luv").update_time`. libuv caches the /// loop's notion of "now" and only refreshes it while `uv.run` /// executes; the loop is idle between batches (and during a slow @@ -214,13 +210,45 @@ pub const LuaRuntime = struct { c.lua_settop(L, c.lua_gettop(L) - 1); // pop the leftover `panto` } + /// Expose the CLI's session agent to extensions as `panto.ext.agent`: + /// a *borrowed* native Agent userdata built by the module's + /// `_wrap_agent`, giving extensions the full agent method surface + /// (`add_system_message`, `compact`, …) on the live session. Must run + /// after `installPantoModule`. Borrowing is safe because the CLI + /// deinits this runtime (closing the Lua state) before the agent. + pub fn setExtAgent(self: *LuaRuntime, agent: *panto.Agent) !void { + const L = self.L; + lua_bridge.pushPantoTable(L); + if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + _ = c.lua_getfield(L, -1, "_wrap_agent"); + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 2); + return RuntimeError.LuaInitFailed; + } + c.lua_pushlightuserdata(L, agent); + if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: _wrap_agent failed"); + c.lua_settop(L, c.lua_gettop(L) - 2); + return RuntimeError.LuaInitFailed; + } + // Stack: ..., panto, agent_ud + lua_bridge.pushExtTable(L); + c.lua_pushvalue(L, -2); // copy agent_ud above ext + c.lua_setfield(L, -2, "agent"); // ext.agent = agent_ud + c.lua_settop(L, c.lua_gettop(L) - 3); // pop ext, agent_ud, panto + } + /// Install the libuv-driven coroutine scheduler: /// - Register `panto._record_result` (C function with `self` as /// light-userdata upvalue) so the wrapper closure can hand /// results back to Zig. /// - Create the wrapper closure that runs a user handler in /// `pcall` and reports the result. - /// - Cache `require("luv").run` for fast per-tick access. + /// - Cache `require("luv").update_time` for the per-batch clock + /// refresh. /// /// Must be called after luarocks bootstrap has installed luv, /// otherwise the `require("luv")` step will fail. Also after @@ -229,7 +257,6 @@ pub const LuaRuntime = struct { pub fn installScheduler(self: *LuaRuntime) !void { try installRecordResult(self); try installWrapperClosure(self); - try cacheUvRun(self); try cacheUvUpdateTime(self); } @@ -252,9 +279,6 @@ pub const LuaRuntime = struct { if (self.wrapper_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref); } - if (self.uv_run_ref != 0) { - c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref); - } if (self.uv_update_time_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_update_time_ref); } @@ -317,11 +341,11 @@ pub const LuaRuntime = struct { c.lua_settop(L, c.lua_gettop(L) - 1); return error.LuaLoadFailed; } - if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { - logTopAsError(L, "lua: failed to evaluate extension"); - c.lua_settop(L, c.lua_gettop(L) - 1); - return error.LuaRunFailed; - } + // The chunk is user Lua: run it through the shared entrypoint + // machinery like everything else (eval must still be + // side-effect-free per the extension contract, but it gets the + // same execution environment). + try lua_bridge.runEntrypointResult(L, 0, "extension eval", null, null); // Stack: ..., returned_value defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop returned value try self.collectEntriesFromTop(script_path, out); @@ -337,13 +361,11 @@ pub const LuaRuntime = struct { out: *std.array_list.Managed(Entry), ) !void { const L = self.L; + // `require` runs the rock's chunk — user Lua — so it goes through + // the shared entrypoint machinery like every other entrypoint. _ = c.lua_getglobal(L, "require"); _ = c.lua_pushlstring(L, module_name.ptr, module_name.len); - if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { - logTopAsError(L, "lua: failed to require rock"); - c.lua_settop(L, c.lua_gettop(L) - 1); - return error.LuaRunFailed; - } + try lua_bridge.runEntrypointResult(L, 1, "rock eval", null, null); // Stack: ..., returned_value defer c.lua_settop(L, c.lua_gettop(L) - 1); try self.collectEntriesFromTop(module_name, out); @@ -458,13 +480,14 @@ pub const LuaRuntime = struct { return error.LuaRunFailed; } } else { + // Run activate() as a user-Lua entrypoint: inside a coroutine + // with the uv loop driven to completion, so activation code may + // await libuv work like any other user Lua. _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, entry.ref); // push activate fn - if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { - logTopAsError(L, "lua: extension activate() failed"); - c.lua_settop(L, c.lua_gettop(L) - 1); + lua_bridge.runEntrypoint(L, 0, "extension activate()", null, null) catch { self.finishEntry(entry); return error.LuaRunFailed; - } + }; } self.finishEntry(entry); @@ -630,17 +653,18 @@ pub const LuaRuntime = struct { } } - /// Run a Lua slash-command handler synchronously. `handler_ref` is a - /// registry ref to a `function(args)` closure; `args` is the trimmed - /// remainder of the command line. The handler runs under `pcall` with - /// a `debug.traceback` error handler so failures surface a readable - /// message instead of crashing the REPL. + /// Run a Lua slash-command handler. `handler_ref` is a registry ref + /// to a `function(args)` closure; `args` is the trimmed remainder of + /// the command line. Like every user-Lua entrypoint it runs inside a + /// coroutine with the uv loop driven to completion, so command + /// handlers may await libuv work; the call is synchronous from the + /// REPL's point of view. /// /// Slash-command handlers act by side effect (printing, mutating /// state) and their return value is ignored. On Lua error, returns /// `RuntimeError.LuaHandlerCrashed` after copying the error message - /// into `err_buf` (truncated to fit); `*err_len` is set to the number - /// of bytes written. + /// (with traceback) into `err_buf` (truncated to fit); `*err_len` is + /// set to the number of bytes written. pub fn runCommand( self: *LuaRuntime, handler_ref: c_int, @@ -651,30 +675,10 @@ pub const LuaRuntime = struct { const L = self.L; err_len.* = 0; - // Install a traceback error handler beneath the call frame. - _ = 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 the `debug` table - const errfunc_idx = c.lua_gettop(L); - - // Push handler + args string. _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); _ = c.lua_pushlstring(L, args.ptr, args.len); - - const rc = c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null); - if (rc != 0) { - var len: usize = 0; - const ptr = c.lua_tolstring(L, -1, &len); - if (ptr != null) { - const n = @min(len, err_buf.len); - @memcpy(err_buf[0..n], ptr[0..n]); - err_len.* = n; - } - c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + errfunc + lua_bridge.runEntrypoint(L, 1, "slash command", err_buf, err_len) catch return RuntimeError.LuaHandlerCrashed; - } - c.lua_settop(L, c.lua_gettop(L) - 1); // pop errfunc } /// Slash commands declared by loaded extensions, in registration @@ -825,7 +829,7 @@ fn runBatch( results: []panto.ToolCallResult, allocator: Allocator, ) !void { - if (self.wrapper_ref == 0 or self.uv_run_ref == 0) { + if (self.wrapper_ref == 0) { // Scheduler not installed. We can still run synchronous // handlers — use the legacy path that drives one coroutine at // a time without an event loop. @@ -1035,22 +1039,18 @@ fn startCoroutine( }; } -/// Call `uv.run("default")`, which runs the event loop until there are -/// no active handles or requests left. Under the async-tool contract -/// (a handler may only block by yielding on a pending libuv operation -/// whose callback resumes it), this returns exactly when every handler -/// coroutine has finished. The boolean return value is discarded. +/// Drain the uv loop (`lua_bridge.drainLoop` — the same drain every +/// single-fn entrypoint uses). Under the async-tool contract (a handler +/// may only block by yielding on a pending libuv operation whose callback +/// resumes it), this returns exactly when every handler coroutine has +/// finished. fn driveUvToCompletion(self: *LuaRuntime) !void { const L = self.L; - _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_run_ref)); - _ = c.lua_pushlstring(L, "default", 7); - if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { + lua_bridge.drainLoop(L) catch { logTopAsError(L, "panto-lua: uv.run failed"); c.lua_settop(L, c.lua_gettop(L) - 1); return error.UvRunFailed; - } - // Discard the boolean return value. - c.lua_settop(L, c.lua_gettop(L) - 1); + }; } /// Pre-scheduler fallback (used in unit tests and during early @@ -1092,10 +1092,11 @@ fn runLegacySync( return .{ .ok = out_parts }; } -/// Run a handler synchronously, with no event loop. On Lua-level -/// failure the error message string is duped into `*err_msg_out` -/// (caller owns) before returning the typed error. `err_msg_out` is -/// only written on the error path; on success it is left untouched. +/// Run a handler through the shared entrypoint machinery (coroutine + +/// loop drain), keeping its return value. On Lua-level failure the error +/// message is duped into `*err_msg_out` (caller owns) before returning +/// the typed error. `err_msg_out` is only written on the error path; on +/// success it is left untouched. fn invokeCoroutineSync( L: *c.lua_State, handler_ref: c_int, @@ -1103,49 +1104,26 @@ fn invokeCoroutineSync( allocator: Allocator, err_msg_out: *?[]u8, ) !panto.ResultParts { - const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; - defer c.lua_settop(L, c.lua_gettop(L) - 1); - - _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); - if (c.lua_type(co, -1) != lua_bridge.T_FUNCTION) { + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 1); return RuntimeError.LuaHandlerNotFound; } var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); - try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input); - - var nresults: c_int = 0; - const status = c.lua_resume(co, L, 1, &nresults); + try lua_bridge.pushJsonAsLua(L, arena_state.allocator(), input); - switch (status) { - c.LUA_OK => { - if (nresults < 1) return RuntimeError.BadHandlerReturn; - return try lua_bridge.readHandlerResult(co, -1, allocator); - }, - c.LUA_YIELD => { - const msg = "lua: tool handler yielded with no event loop installed; call installScheduler() before dispatching"; - if (@import("builtin").is_test) { - std.log.warn("{s}", .{msg}); - } else { - std.log.err("{s}", .{msg}); - } - return RuntimeError.LuaHandlerYielded; - }, - else => { - // Capture the Lua error message into the caller's slot - // *before* logging+returning, so the legacy sync path can - // surface it to the model rather than just the typed - // error name. - var msg_len: usize = 0; - const msg_ptr = c.lua_tolstring(co, -1, &msg_len); - if (msg_ptr != null) { - err_msg_out.* = allocator.dupe(u8, msg_ptr[0..msg_len]) catch null; - } - logTopAsError(co, "lua: handler crashed"); - return RuntimeError.LuaHandlerCrashed; - }, - } + var err_buf: [1024]u8 = undefined; + var err_len: usize = 0; + lua_bridge.runEntrypointResult(L, 1, "tool handler", &err_buf, &err_len) catch { + err_msg_out.* = allocator.dupe(u8, err_buf[0..err_len]) catch null; + return RuntimeError.LuaHandlerCrashed; + }; + // Success: the handler's result is on top of L. + defer c.lua_settop(L, c.lua_gettop(L) - 1); + if (c.lua_type(L, -1) == lua_bridge.T_NIL) return RuntimeError.BadHandlerReturn; + return try lua_bridge.readHandlerResult(L, -1, allocator); } // --------------------------------------------------------------------------- @@ -1249,30 +1227,6 @@ fn installWrapperClosure(self: *LuaRuntime) !void { /// Cache `require("luv").run` in the registry so the scheduler can /// invoke it cheaply without re-resolving `require` each batch. -fn cacheUvRun(self: *LuaRuntime) !void { - const L = self.L; - const snippet = - \\local uv = require("luv") - \\return uv.run - ; - if (c.luaL_loadstring(L, snippet) != 0) { - logTopAsError(L, "panto-lua: failed to compile luv lookup"); - c.lua_settop(L, c.lua_gettop(L) - 1); - return RuntimeError.LuaInitFailed; - } - if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { - logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)"); - c.lua_settop(L, c.lua_gettop(L) - 1); - return RuntimeError.LuaInitFailed; - } - // Stack: [..., uv.run]. luaL_ref pops the top. - if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { - c.lua_settop(L, c.lua_gettop(L) - 1); - return RuntimeError.LuaInitFailed; - } - self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); -} - /// Cache `require("luv").update_time` so the scheduler can refresh the /// loop clock at the start of each batch. See `uv_update_time_ref`. fn cacheUvUpdateTime(self: *LuaRuntime) !void { @@ -1743,7 +1697,7 @@ test "directory-style extension can require sibling modules" { try testing.expectEqualStrings("HI!", okText(results[0])); } -test "yielding handler with no event loop surfaces LuaHandlerYielded" { +test "yielding handler with no event loop surfaces a suspension error" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1768,15 +1722,15 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { defer freeResults(&results); // Same policy as the crash test: the failure is surfaced as `.ok` - // text so libpanto doesn't abort the turn. The error type name - // (`LuaHandlerYielded`) is included via `@errorName` in the legacy - // sync path's formatToolError call. + // text so libpanto doesn't abort the turn. The message is the shared + // entrypoint machinery's post-drain suspension diagnosis (without luv + // the drain is a no-op, so the yield can never be resumed). try testing.expect(std.mem.startsWith( u8, okText(results[0]), "panto-lua: tool 'sleeper' failed:", )); - try testing.expect(std.mem.indexOf(u8, okText(results[0]), "LuaHandlerYielded") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "suspended after the uv loop drained") != null); } // Integration test: requires a data home with luv already installed. diff --git a/src/main.zig b/src/main.zig index 86f1cb1..02c0045 100644 --- a/src/main.zig +++ b/src/main.zig @@ -383,6 +383,10 @@ pub fn main(init: std.process.Init) !void { // any extension loads (which `require('panto')`). try rt.installPantoModule(); + // Hand extensions the live session agent (`panto.ext.agent`) so an + // `activate()` can act on the session directly (add_system_message…). + try rt.setExtAgent(agent); + // luv is installed (or already present) at this point; wire the // libuv-driven coroutine scheduler before any extensions get a // chance to register tools that might want to yield. diff --git a/src/tui_app.zig b/src/tui_app.zig index 88c2c66..d097225 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -245,6 +245,14 @@ const Entry = struct { } }; +/// A staged `tool_result` output override: the replacement text a handler +/// assigned to `ev.output`, keyed by the call it belongs to. Both slices +/// owned by the App's allocator. +const PendingOutputOverride = struct { + id: []u8, + text: []u8, +}; + // =========================================================================== // App // =========================================================================== @@ -279,6 +287,19 @@ pub const App = struct { /// tool output starts collapsed to its last few lines. tools_collapsed: bool = true, + /// Staged writable-event-field overrides (taken off the bus right after + /// the corresponding fire), waiting for the turn driver — which owns the + /// agent — to apply them to the conversation: + /// - input overrides: tool_use_id → replacement input JSON, staged at + /// `tool_call_complete` (mid-stream), applied at `tool_dispatch_start` + /// once the assistant message is committed but before dispatch; + /// - output overrides: staged at `tool_result`, applied right after + /// `tool_dispatch_complete` routing, before the next provider call. + /// Keys and values owned by `alloc`. Cleared as applied (and defensively + /// at turn start). + pending_input_overrides: std.StringHashMapUnmanaged([]u8) = .empty, + pending_output_overrides: std.ArrayListUnmanaged(PendingOutputOverride) = .empty, + /// Optional override-release hook. When a slot's `override` is REPLACED by /// a newer override (a second handler swap on the same slot), the old /// override is no longer referenced by panto and its OWNER must release it. @@ -340,6 +361,24 @@ pub const App = struct { self.transcript.deinit(self.alloc); self.router.deinit(); self.bus.deinit(); + self.clearPendingOverrides(); + self.pending_input_overrides.deinit(self.alloc); + self.pending_output_overrides.deinit(self.alloc); + } + + /// Drop all staged (unapplied) writable-field overrides. + fn clearPendingOverrides(self: *App) void { + var it = self.pending_input_overrides.iterator(); + while (it.next()) |entry| { + self.alloc.free(entry.key_ptr.*); + self.alloc.free(entry.value_ptr.*); + } + self.pending_input_overrides.clearRetainingCapacity(); + for (self.pending_output_overrides.items) |po| { + self.alloc.free(po.id); + self.alloc.free(po.text); + } + self.pending_output_overrides.clearRetainingCapacity(); } /// Access the event bus so the embedder (and, later, the Lua bridge) can @@ -641,6 +680,42 @@ pub const App = struct { } } _ = try self.fireForEntry(entry, lc, name, enriched); + + // Writable-field overrides: take what a handler wrote during this + // fire and stage it by call id for the turn driver (which owns the + // agent) to apply at the effective boundary. An override without a + // resolved call id has nothing to attach to and is dropped. + const is_result = std.mem.eql(u8, name, "tool_result"); + const is_call_complete = std.mem.eql(u8, name, "tool_call_complete"); + if (!is_result and !is_call_complete) return; + const value = self.bus.takeOverride() orelse return; + const id = enriched.tool.id; + if (id.len == 0) { + self.alloc.free(value); + return; + } + if (is_result) { + const id_copy = self.alloc.dupe(u8, id) catch |e| { + self.alloc.free(value); + return e; + }; + try self.pending_output_overrides.append(self.alloc, .{ .id = id_copy, .text = value }); + } else { + const gop = self.pending_input_overrides.getOrPut(self.alloc, id) catch |e| { + self.alloc.free(value); + return e; + }; + if (gop.found_existing) { + self.alloc.free(gop.value_ptr.*); + } else { + gop.key_ptr.* = self.alloc.dupe(u8, id) catch |e| { + _ = self.pending_input_overrides.remove(id); + self.alloc.free(value); + return e; + }; + } + gop.value_ptr.* = value; + } } /// Fire a thinking/assistant lifecycle event for the entry backing a @@ -1029,6 +1104,7 @@ pub const App = struct { try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{ .tool_name = if (box.name) |n| n.items else "", .id = tr.tool_use_id, + .input = box.input.items, .output = text.items, } }); any = true; @@ -1043,6 +1119,9 @@ pub const App = struct { /// the chat history); only the block-index map is cleared. pub fn beginTurn(self: *App) void { self.router.reset(); + // Defensive: an interrupted turn may have staged overrides it never + // applied; they must not leak into this turn's calls. + self.clearPendingOverrides(); } /// Surface a turn error as a dim status line in the transcript. @@ -1934,7 +2013,13 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp } // Model turn. Echo the user message, then pump the stream into components. - try app.spawnUser(line); + try app.spawnUser(line); // fires `user_message` + // A `user_message` handler may have overridden `ev.text`: the echo above + // keeps what the user typed; the override is what the turn sends. (The + // fire itself clears any stale override a replayed user_message left.) + const text_override = app.bus.takeOverride(); + defer if (text_override) |t| app.alloc.free(t); + const turn_text = text_override orelse line; app.beginTurn(); try app.renderNow(); @@ -1947,7 +2032,7 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp return; }; - driveTurn(app, term, opts, line) catch |err| { + driveTurn(app, term, opts, turn_text) catch |err| { try app.routeError(err); }; try app.renderNow(); @@ -2069,6 +2154,7 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const }; const e = ev orelse break; try app.routeEvent(e); + try applyPendingOverrides(app, opts.agent, e); _ = try app.maybeRender(); if (try pumpTurnKeys(app, term, &turn_input_tail)) { interrupted = true; @@ -2084,6 +2170,36 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const } } +/// Apply staged writable-event-field overrides at their effective stream +/// boundaries. Runs right after `routeEvent` (which is where the fires that +/// stage them happen), still before the next `stream.next()` — i.e. before +/// the agent advances — so the mutation is authoritative for dispatch, the +/// next provider request, and turn persistence: +/// - `tool_dispatch_start`: the assistant message is committed but tools +/// have not run — rewrite ToolUse inputs staged at `tool_call_complete`. +/// - `tool_dispatch_complete`: results are in the conversation tail — +/// rewrite ToolResult text staged at `tool_result`. +/// An override whose call id no longer matches (e.g. a stale stage after a +/// reopen) is dropped silently — the conversation stays untouched. +fn applyPendingOverrides(app: *App, agent: *panto.Agent, ev: Event) !void { + switch (ev) { + .tool_dispatch_start => { + defer app.clearPendingOverrides(); + var it = app.pending_input_overrides.iterator(); + while (it.next()) |entry| { + _ = try agent.overrideToolUseInput(entry.key_ptr.*, entry.value_ptr.*); + } + }, + .tool_dispatch_complete => { + defer app.clearPendingOverrides(); + for (app.pending_output_overrides.items) |po| { + _ = try agent.overrideToolResultOutput(po.id, po.text); + } + }, + else => {}, + } +} + /// Service app-level keybindings while a turn is in flight. Returns true when /// the user requested an interrupt (Escape). Text-editing keys are ignored: the /// input box is restored only after the interrupted turn is back in user-input @@ -2967,6 +3083,60 @@ test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their bounda try testing.expectEqual(@as(usize, 1), counter.result); } +test "writable-field overrides are staged by call id at tool_call_complete and tool_result" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // A native handler standing in for a Lua `ev.input = ...` / `ev.output + // = ...` assignment (the bridge routes those to the same bus slot). + const Writer = struct { + bus: *EventBus, + fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { + const w: *@This() = @ptrCast(@alignCast(ctx)); + if (std.mem.eql(u8, ev.name, "tool_call_complete")) { + w.bus.setOverride("{\"a\":2}") catch {}; + } else if (std.mem.eql(u8, ev.name, "tool_result")) { + w.bus.setOverride("redacted") catch {}; + } + } + }; + var w = Writer{ .bus = &h.app.bus }; + try h.app.bus.on("tool_call_complete", .{ .ctx = &w, .callback = Writer.cb }); + try h.app.bus.on("tool_result", .{ .ctx = &w, .callback = Writer.cb }); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); + var tu = panto.ToolUseBlock{ + .id = try alloc.dupe(u8, "a"), + .name = try alloc.dupe(u8, "read"), + }; + defer tu.deinit(alloc); + try tu.input.appendSlice(alloc, "{\"a\":1}"); + try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } }); + + // The input override is staged, keyed by the resolved call id. + try testing.expectEqual(@as(usize, 1), h.app.pending_input_overrides.count()); + try testing.expectEqualStrings("{\"a\":2}", h.app.pending_input_overrides.get("a").?); + + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + var text: panto.TextualBlock = .empty; + try text.appendSlice(alloc, "out"); + try parts.append(alloc, .{ .text = text }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } }); + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // The output override is staged with the id it belongs to. + try testing.expectEqual(@as(usize, 1), h.app.pending_output_overrides.items.len); + try testing.expectEqualStrings("a", h.app.pending_output_overrides.items[0].id); + try testing.expectEqualStrings("redacted", h.app.pending_output_overrides.items[0].text); + + // Nothing left on the bus once staged. + try testing.expect(h.app.bus.takeOverride() == null); +} + test "event wiring: thinking lifecycle fires start + per-delta + complete" { const alloc = testing.allocator; const h = try Harness.make(alloc); diff --git a/src/tui_event.zig b/src/tui_event.zig index 3f59ba2..ec860cb 100644 --- a/src/tui_event.zig +++ b/src/tui_event.zig @@ -238,6 +238,21 @@ pub const Event = struct { } }; +/// The writable payload field for an event type, or null when the event is +/// observe-only. This is the single authority for the "override via mutable +/// attribute" paradigm: a Lua handler assigning `ev.<field> = value` on one +/// of these events records an override on the bus; the firer takes it after +/// dispatch and applies it to the real underlying state (conversation +/// block, turn text). Values are strings on the Lua side except +/// `tool_call_complete.input`, which is assigned as a JSON-able table and +/// serialized at write time. +pub fn writableField(event_name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, event_name, "tool_result")) return "output"; + if (std.mem.eql(u8, event_name, "user_message")) return "text"; + if (std.mem.eql(u8, event_name, "tool_call_complete")) return "input"; + return null; +} + // =========================================================================== // EventBus // =========================================================================== @@ -259,6 +274,14 @@ pub const EventBus = struct { handlers: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Handler)) = .empty, /// Owned copies of the event-name keys (the map borrows these). keys: std.ArrayListUnmanaged([]u8) = .empty, + /// Dispatch-scoped payload-field override written by a handler during + /// the current fire (the Lua bridge's writable event fields, e.g. + /// `ev.output = ...` on `tool_result`). One slot suffices: each + /// writable event type has exactly one writable field (see + /// `writableField`), `emit` clears the slot at entry for writable + /// events, and the firer takes it immediately after the fire returns. + /// Owned by `alloc`. + override_value: ?[]u8 = null, pub fn init(alloc: std.mem.Allocator) EventBus { return .{ .alloc = alloc }; @@ -270,6 +293,27 @@ pub const EventBus = struct { self.handlers.deinit(self.alloc); for (self.keys.items) |k| self.alloc.free(k); self.keys.deinit(self.alloc); + if (self.override_value) |v| self.alloc.free(v); + } + + /// Record a handler's override of the current event's writable field + /// (last writer wins — matches `setComponent` semantics). Readers of + /// that field during the same dispatch see this value, so handlers + /// chain in registration order. + pub fn setOverride(self: *EventBus, value: []const u8) !void { + const copy = try self.alloc.dupe(u8, value); + if (self.override_value) |old| self.alloc.free(old); + self.override_value = copy; + } + + /// Take ownership of the pending override (null if no handler wrote + /// one). The caller frees the returned slice with `bus.alloc` and is + /// responsible for applying it to the real underlying state — an + /// untaken override is simply dropped at the next writable fire. + pub fn takeOverride(self: *EventBus) ?[]u8 { + const v = self.override_value; + self.override_value = null; + return v; } /// Register `handler` for `name`. Handlers fire in registration order on @@ -303,6 +347,12 @@ pub const EventBus = struct { /// concurrent `tool` boundaries each pass their own `event` (with their own /// default) and get back their own chosen component. pub fn emit(self: *EventBus, event: *Event) ?Component { + // Writable events start with a clean override slot, so a stale + // value from a fire nobody took (e.g. a replayed `user_message`) + // can never leak into this dispatch. + if (writableField(event.name) != null) { + if (self.takeOverride()) |stale| self.alloc.free(stale); + } if (self.handlers.getPtr(event.name)) |list| { for (list.items) |h| h.call(event); } |
