diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/extension_loader.zig | 49 | ||||
| -rw-r--r-- | src/lua_bridge.zig | 151 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 134 |
3 files changed, 242 insertions, 92 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig index b84eba5..6d470cc 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -491,6 +491,27 @@ fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !v const testing = std.testing; +/// Test helper: first text part of a tool result. +fn xokText(result: panto.ToolCallResult) []const u8 { + switch (result) { + .ok => |parts| { + for (parts) |p| { + if (p == .text) return p.text; + } + return ""; + }, + .err => return "", + } +} + +/// Test helper: free a results slice (parts on `.ok`). +fn xfreeResults(results: []panto.ToolCallResult) void { + for (results) |r| switch (r) { + .ok => |b| panto.freeResultParts(testing.allocator, b), + .err => {}, + }; +} + fn writeFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void { try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content }); } @@ -643,8 +664,8 @@ test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" { const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("PROJECT", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("PROJECT", xokText(results[0])); } test "loadFromDirs: tool-name collision between extensions errors" { @@ -711,8 +732,8 @@ test "loadFromDirs: tools/ directory — single-file tool form" { const calls = [_]panto.ToolCall{.{ .tool_name = "echo", .input = "{\"msg\":\"hi\"}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("echo: hi", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("echo: hi", xokText(results[0])); } test "loadFromDirs: tools/ directory — directory-style tool with sibling require" { @@ -751,8 +772,8 @@ test "loadFromDirs: tools/ directory — directory-style tool with sibling requi const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("HI!", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("HI!", xokText(results[0])); } test "loadFromDirs: project tool shadows user tool of the same name" { @@ -799,8 +820,8 @@ test "loadFromDirs: project tool shadows user tool of the same name" { const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("PROJECT", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("PROJECT", xokText(results[0])); } test "loadFromDirs: project tool shadows user shadows base" { @@ -858,8 +879,8 @@ test "loadFromDirs: project tool shadows user shadows base" { const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("PROJECT", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("PROJECT", xokText(results[0])); } test "loadFromDirs: user tool shadows base tool when no project entry" { @@ -905,8 +926,8 @@ test "loadFromDirs: user tool shadows base tool when no project entry" { const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("USER", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("USER", xokText(results[0])); } test "loadFromDirs: extension and tool share a *file* name independently" { @@ -996,8 +1017,8 @@ test "loadFromDirs: tools policy removes a denied registered tool name" { const calls = [_]panto.ToolCall{.{ .tool_name = "std.read", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("READ", results[0].ok); + defer xfreeResults(&results); + try testing.expectEqualStrings("READ", xokText(results[0])); } test "loadFromDirs: extensions policy denies a whole entry before it loads" { diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index cb57ade..245b0ec 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -36,6 +36,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const panto = @import("panto"); pub const c = @cImport({ @cInclude("lua.h"); @@ -260,20 +261,118 @@ pub fn pushJsonAsLua( pushJsonValue(L, parsed.value) catch return BridgeError.OutOfMemory; } -/// Read a Lua value at `idx` and serialize it to a JSON-compatible owned -/// byte string. Used to convert handler return values into ToolResult -/// content. For now we only accept string returns; extending to richer -/// types is straightforward but unnecessary for slice 2. +/// Read a tool handler's return value at `idx` and convert it into an +/// owned `[]panto.ResultPart`. Two accepted shapes: +/// +/// * a plain string -> one `.text` part; +/// * a table `{ text = "...", attachments = { { media_type = "...", +/// data = "..." }, ... } }` -> an optional `.text` part followed by +/// one `.media` part per attachment (`data` is base64-encoded bytes). +/// +/// Every returned slice/part owns its bytes (allocated with `allocator`); +/// the caller frees via `panto.freeResultParts`. pub fn readHandlerResult( L: *c.lua_State, idx: c_int, allocator: Allocator, -) BridgeError![]u8 { - if (c.lua_type(L, idx) != T_STRING) return BridgeError.BadHandlerReturn; +) BridgeError![]panto.ResultPart { + const ty = c.lua_type(L, idx); + if (ty == T_STRING) { + var len: usize = 0; + const ptr = c.lua_tolstring(L, idx, &len); + if (ptr == null) return BridgeError.BadHandlerReturn; + return (panto.textResult(allocator, ptr[0..len]) catch + BridgeError.OutOfMemory); + } + if (ty != T_TABLE) return BridgeError.BadHandlerReturn; + return readHandlerResultTable(L, idx, allocator); +} + +/// Read a string field `name` from the table at `tbl_idx`. Returns null +/// if absent/nil, an error if present-but-not-a-string. The returned +/// slice borrows from Lua's internal buffer — copy before popping. +fn optStringField(L: *c.lua_State, tbl_idx: c_int, name: [*:0]const u8) BridgeError!?[]const u8 { + const t = c.lua_getfield(L, tbl_idx, name); + if (t == T_NIL) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return null; + } + if (t != T_STRING) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return BridgeError.BadHandlerReturn; + } var len: usize = 0; - const ptr = c.lua_tolstring(L, idx, &len); - if (ptr == null) return BridgeError.BadHandlerReturn; - return allocator.dupe(u8, ptr[0..len]) catch BridgeError.OutOfMemory; + const ptr = c.lua_tolstring(L, -1, &len); + if (ptr == null) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return BridgeError.BadHandlerReturn; + } + // Note: value still on stack; caller pops after copying. + return ptr[0..len]; +} + +fn readHandlerResultTable( + L: *c.lua_State, + idx: c_int, + allocator: Allocator, +) BridgeError![]panto.ResultPart { + var parts: std.ArrayList(panto.ResultPart) = .empty; + errdefer { + for (parts.items) |p| p.deinit(allocator); + parts.deinit(allocator); + } + + // Optional `text` field. + { + const maybe = try optStringField(L, idx, "text"); + if (maybe) |s| { + const owned = allocator.dupe(u8, s) catch return BridgeError.OutOfMemory; + c.lua_settop(L, c.lua_gettop(L) - 1); // pop field value + parts.append(allocator, .{ .text = owned }) catch { + allocator.free(owned); + return BridgeError.OutOfMemory; + }; + } + } + + // Optional `attachments` array. + { + const t = c.lua_getfield(L, idx, "attachments"); + defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachments + if (t == T_TABLE) { + const n: usize = @intCast(c.lua_rawlen(L, -1)); + var i: usize = 1; + while (i <= n) : (i += 1) { + _ = c.lua_rawgeti(L, -1, @intCast(i)); // push attachment table + defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachment + if (c.lua_type(L, -1) != T_TABLE) return BridgeError.BadHandlerReturn; + + // `media_type` is an optional hint; libpanto detects the + // type from the raw bytes when it's absent. + const media_type: ?[]const u8 = if (try optStringField(L, -1, "media_type")) |mt_slice| blk: { + const owned = allocator.dupe(u8, mt_slice) catch return BridgeError.OutOfMemory; + c.lua_settop(L, c.lua_gettop(L) - 1); // pop media_type value + break :blk owned; + } else null; + errdefer if (media_type) |mt| allocator.free(mt); + + const data_slice = (try optStringField(L, -1, "data")) orelse + return BridgeError.BadHandlerReturn; + const data = allocator.dupe(u8, data_slice) catch return BridgeError.OutOfMemory; + c.lua_settop(L, c.lua_gettop(L) - 1); // pop data value + errdefer allocator.free(data); + + parts.append(allocator, .{ .media = .{ + .media_type = media_type, + .data = data, + } }) catch return BridgeError.OutOfMemory; + } + } else if (t != T_NIL) { + return BridgeError.BadHandlerReturn; + } + } + + return parts.toOwnedSlice(allocator) catch BridgeError.OutOfMemory; } // --------------------------------------------------------------------------- @@ -697,8 +796,38 @@ test "handler invocation: input parsed, result captured" { } const result = try readHandlerResult(L, -1, std.testing.allocator); - defer std.testing.allocator.free(result); - try std.testing.expectEqualStrings("got: hello", result); + defer panto.freeResultParts(std.testing.allocator, result); + try std.testing.expectEqual(@as(usize, 1), result.len); + try std.testing.expectEqualStrings("got: hello", result[0].text); +} + +test "readHandlerResult: table with text and attachments" { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + install(L); + + const script = + \\return { + \\ text = "see image", + \\ attachments = { + \\ { media_type = "image/png", data = "AAA=" }, + \\ { media_type = "application/pdf", data = "BBB=" }, + \\ }, + \\} + ; + if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + return error.LuaScriptFailed; + } + + const result = try readHandlerResult(L, -1, std.testing.allocator); + defer panto.freeResultParts(std.testing.allocator, result); + try std.testing.expectEqual(@as(usize, 3), result.len); + try std.testing.expectEqualStrings("see image", result[0].text); + try std.testing.expectEqualStrings("image/png", result[1].media.media_type.?); + try std.testing.expectEqualStrings("AAA=", result[1].media.data); + try std.testing.expectEqualStrings("application/pdf", result[2].media.media_type.?); + try std.testing.expectEqualStrings("BBB=", result[2].media.data); } test "handler crash: error message surfaces via xpcall traceback hook" { diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 7d18fe9..73078c0 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -548,9 +548,9 @@ const Slot = struct { /// `true` if the handler returned cleanly, `false` if it raised /// via the `pcall` wrapping. ok: bool = false, - /// Result payload as owned bytes. Allocated from `allocator`. - /// Caller frees. - value: ?[]u8 = null, + /// Result payload as owned parts. Allocated from `allocator`. + /// Caller frees via `panto.freeResultParts`. + value: ?[]panto.ResultPart = null, /// On `ok = false`, an owned copy of the error message. err_msg: ?[]u8 = null, }; @@ -703,11 +703,11 @@ fn runBatch( continue; } if (slot.ok) { - results[i] = .{ .ok = slot.value orelse try allocator.dupe(u8, "") }; + results[i] = .{ .ok = slot.value orelse try panto.textResult(allocator, "") }; // Free the err_msg if both ended up set somehow. if (slot.err_msg) |m| allocator.free(m); } else { - if (slot.value) |v| allocator.free(v); + if (slot.value) |v| panto.freeResultParts(allocator, v); std.log.warn( "panto-lua: tool '{s}' failed: {s}", .{ @@ -734,12 +734,13 @@ fn formatToolError( allocator: Allocator, tool_name: []const u8, message: []const u8, -) ![]u8 { - return std.fmt.allocPrint( +) ![]panto.ResultPart { + const text = try std.fmt.allocPrint( allocator, "panto-lua: tool '{s}' failed: {s}", .{ tool_name, message }, ); + return panto.ownedTextResult(allocator, text); } /// Start one coroutine: create a thread under the runtime's lua_State, @@ -815,7 +816,7 @@ fn runLegacySync( }; var err_msg: ?[]u8 = null; defer if (err_msg) |m| allocator.free(m); - const out_bytes = invokeCoroutineSync( + const out_parts = invokeCoroutineSync( self.L, handler_ref, call.input, @@ -834,7 +835,7 @@ fn runLegacySync( ) catch return .{ .err = error.OutOfMemory }; return .{ .ok = bytes }; }; - return .{ .ok = out_bytes }; + return .{ .ok = out_parts }; } /// Run a handler synchronously, with no event loop. On Lua-level @@ -847,7 +848,7 @@ fn invokeCoroutineSync( input: []const u8, allocator: Allocator, err_msg_out: *?[]u8, -) ![]u8 { +) ![]panto.ResultPart { const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; defer c.lua_settop(L, c.lua_gettop(L) - 1); @@ -1058,6 +1059,29 @@ fn logTopAsError(L: *c.lua_State, prefix: []const u8) void { const testing = std.testing; +/// Test helper: concatenated text across a result's text parts. +/// Returns the first text part's items (sufficient for current tests, +/// which return a single text part). +fn okText(result: panto.ToolCallResult) []const u8 { + switch (result) { + .ok => |parts| { + for (parts) |p| { + if (p == .text) return p.text; + } + return ""; + }, + .err => return "", + } +} + +/// Test helper: free a results slice (parts on `.ok`). +fn freeResults(results: []panto.ToolCallResult) void { + for (results) |r| switch (r) { + .ok => |b| panto.freeResultParts(testing.allocator, b), + .err => {}, + }; +} + fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 { try dir.writeFile(testing.io, .{ .sub_path = name, .data = source }); var buf: [std.fs.max_path_bytes]u8 = undefined; @@ -1204,14 +1228,11 @@ test "invokeBatch runs each call through a coroutine and returns the result" { }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expectEqualStrings("got: hello", results[0].ok); - try testing.expectEqualStrings("HI!", results[1].ok); - try testing.expectEqualStrings("got: again", results[2].ok); + try testing.expectEqualStrings("got: hello", okText(results[0])); + try testing.expectEqualStrings("HI!", okText(results[1])); + try testing.expectEqualStrings("got: again", okText(results[2])); } test "module-global state survives across calls in the same runtime" { @@ -1250,14 +1271,11 @@ test "module-global state survives across calls in the same runtime" { }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expectEqualStrings("1", results[0].ok); - try testing.expectEqualStrings("2", results[1].ok); - try testing.expectEqualStrings("3", results[2].ok); + try testing.expectEqualStrings("1", okText(results[0])); + try testing.expectEqualStrings("2", okText(results[1])); + try testing.expectEqualStrings("3", okText(results[2])); // And a second batch keeps the counter going. var more: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; @@ -1267,8 +1285,8 @@ test "module-global state survives across calls in the same runtime" { &more, testing.allocator, ); - defer testing.allocator.free(more[0].ok); - try testing.expectEqualStrings("4", more[0].ok); + defer freeResults(&more); + try testing.expectEqualStrings("4", okText(more[0])); } test "handler crash: per-call error surfaces, sibling calls succeed" { @@ -1306,12 +1324,9 @@ test "handler crash: per-call error surfaces, sibling calls succeed" { .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expectEqualStrings("fine", results[0].ok); + try testing.expectEqualStrings("fine", okText(results[0])); // A handler crash is surfaced as an *ok* result whose payload is // the formatted error message — not as `.err` — because libpanto // would otherwise abort the entire turn on per-call `.err`. The @@ -1319,11 +1334,11 @@ test "handler crash: per-call error surfaces, sibling calls succeed" { // prefix and includes the Lua-side error message. try testing.expect(std.mem.startsWith( u8, - results[1].ok, + okText(results[1]), "panto-lua: tool 'boom' failed:", )); - try testing.expect(std.mem.indexOf(u8, results[1].ok, "kaboom") != null); - try testing.expectEqualStrings("fine", results[2].ok); + try testing.expect(std.mem.indexOf(u8, okText(results[1]), "kaboom") != null); + try testing.expectEqualStrings("fine", okText(results[2])); } test "directory-style extension can require sibling modules" { @@ -1369,8 +1384,8 @@ test "directory-style extension can require sibling modules" { const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer testing.allocator.free(results[0].ok); - try testing.expectEqualStrings("HI!", results[0].ok); + defer freeResults(&results); + try testing.expectEqualStrings("HI!", okText(results[0])); } test "yielding handler with no event loop surfaces LuaHandlerYielded" { @@ -1395,10 +1410,7 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { const calls = [_]panto.ToolCall{.{ .tool_name = "sleeper", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + 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 @@ -1406,10 +1418,10 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { // sync path's formatToolError call. try testing.expect(std.mem.startsWith( u8, - results[0].ok, + okText(results[0]), "panto-lua: tool 'sleeper' failed:", )); - try testing.expect(std.mem.indexOf(u8, results[0].ok, "LuaHandlerYielded") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "LuaHandlerYielded") != null); } // Integration test: requires a `$PANTO_HOME` with luv already @@ -1488,13 +1500,10 @@ test "scheduler: yielding handler is resumed by libuv" { .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expectEqualStrings("awake", results[0].ok); - try testing.expectEqualStrings("awake", results[1].ok); + try testing.expectEqualStrings("awake", okText(results[0])); + try testing.expectEqualStrings("awake", okText(results[1])); } // Contract-violation guard: a handler that yields WITHOUT arming any @@ -1537,14 +1546,11 @@ test "scheduler: handler that yields without arming libuv work is surfaced as an .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expect(std.mem.startsWith(u8, results[0].ok, "panto-lua: tool 'abandon' failed:")); - try testing.expect(std.mem.indexOf(u8, results[0].ok, "still suspended") != null); - try testing.expectEqualStrings("ok", results[1].ok); + try testing.expect(std.mem.startsWith(u8, okText(results[0]), "panto-lua: tool 'abandon' failed:")); + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "still suspended") != null); + try testing.expectEqualStrings("ok", okText(results[1])); } /// Absolute path to the repo's `agent/tools` directory, derived from @@ -1618,13 +1624,10 @@ test "scheduler: two real std.shell calls in one batch do not deadlock" { .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expect(std.mem.indexOf(u8, results[0].ok, "first-done") != null); - try testing.expect(std.mem.indexOf(u8, results[1].ok, "second-done") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "first-done") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[1]), "second-done") != null); } // Reproduction: two REAL `std.read` calls in one batch. read.lua uses @@ -1658,13 +1661,10 @@ test "scheduler: two real std.read calls in one batch do not deadlock" { }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer for (results) |r| switch (r) { - .ok => |b| testing.allocator.free(b), - .err => {}, - }; + defer freeResults(&results); - try testing.expect(results[0].ok.len > 0); - try testing.expect(results[1].ok.len > 0); + try testing.expect(okText(results[0]).len > 0); + try testing.expect(okText(results[1]).len > 0); } test "loadExtension: duplicate tool name from a second extension errors" { |
