Merge pull request #2576 from lightpanda-io/agent_save_cmd

agent: add REPL /save command for recorded sessions
This commit is contained in:
Adrià Arrufat
2026-05-29 15:55:49 +02:00
committed by GitHub
3 changed files with 264 additions and 12 deletions

View File

@@ -141,6 +141,8 @@ node_registry: CDPNode.Registry,
terminal: Terminal,
verifier: Verifier,
recorder: ?Recorder,
save_buffer: Recorder.Memory,
save_path: ?[]u8,
messages: std.ArrayList(zenai.provider.Message),
message_arena: std.heap.ArenaAllocator,
model: []u8,
@@ -242,6 +244,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
.terminal = .init(allocator, history_path, Config.agentVerbosity(opts), will_repl),
.verifier = undefined,
.recorder = null,
.save_buffer = .init(allocator),
.save_path = null,
.messages = .empty,
.message_arena = .init(allocator),
.model = model,
@@ -303,6 +307,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
pub fn deinit(self: *Agent) void {
self.terminal.uninstallLogSink();
if (self.recorder) |*r| r.deinit();
self.save_buffer.deinit();
if (self.save_path) |p| self.allocator.free(p);
self.terminal.deinit();
self.message_arena.deinit();
self.messages.deinit(self.allocator);
@@ -389,6 +395,7 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
pub const TurnInput = struct {
prompt: []const u8,
record_comment: ?[]const u8 = null,
capture_for_save: bool = false,
attachments: ?[]const []const u8 = null,
label: []const u8 = "Request",
};
@@ -494,7 +501,7 @@ fn runRepl(self: *Agent) void {
self.terminal.printError("Basic REPL (--no-llm) accepts only slash commands. Try /help, or drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY) to enable natural-language prompts.", .{});
continue :repl;
}
_ = self.runTurn(.{ .prompt = line, .record_comment = line });
_ = self.runTurn(.{ .prompt = line, .record_comment = line, .capture_for_save = true });
continue :repl;
},
else => |e| {
@@ -514,7 +521,7 @@ fn runRepl(self: *Agent) void {
.login, .acceptCookies => {
const label: []const u8 = if (cmd == .login) "/login" else "/acceptCookies";
const prompt = if (cmd == .login) login_prompt else accept_cookies_prompt;
_ = self.runTurn(.{ .prompt = prompt, .record_comment = line, .label = label });
_ = self.runTurn(.{ .prompt = prompt, .record_comment = line, .capture_for_save = true, .label = label });
},
.tool_call => |tc| {
self.terminal.beginTool(tc.name(), slash_split.?.rest);
@@ -522,6 +529,7 @@ fn runRepl(self: *Agent) void {
self.terminal.endTool();
self.printCommandResult(cmd, result);
if (self.recorder) |*r| r.record(cmd);
self.recordSaveCommand(cmd);
self.recordSlashToolCall(trimmed, tc.name(), tc.args, result) catch |err| {
self.terminal.printWarning("LLM conversation out of sync (/{s}: {s}); next prompt may not see this action", .{ tc.name(), @errorName(err) });
};
@@ -532,14 +540,15 @@ fn runRepl(self: *Agent) void {
self.terminal.printInfo("Goodbye!", .{});
}
/// Handle a meta slash command (/quit, /help, /verbosity). These aren't part
/// of PandaScript — they're REPL-only and never recorded. Returns `true` if
/// the user asked to quit.
/// Handle a REPL-only meta slash command. These aren't part of PandaScript
/// and never reach the browser tool dispatcher. Returns `true` if the user
/// asked to quit.
fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.MetaCommand, rest: []const u8) bool {
switch (meta.tag) {
.quit => return true,
.help => self.printSlashHelp(arena, rest),
.verbosity => self.handleVerbosity(rest),
.save => self.handleSave(arena, rest),
}
return false;
}
@@ -557,6 +566,145 @@ fn handleVerbosity(self: *Agent, rest: []const u8) void {
self.terminal.printInfo("verbosity: {s}", .{@tagName(level)});
}
const SaveMode = enum { replace, append };
fn handleSave(self: *Agent, arena: std.mem.Allocator, rest: []const u8) void {
const filename = parseSaveFilename(rest) catch |err| {
const msg: []const u8 = switch (err) {
error.TooManyArguments => "usage: /save [filename.lp]",
error.UnterminatedQuote => "unterminated filename quote",
error.EmptyFilename => "filename cannot be empty",
error.InvalidFilename => "filename must be a local file name, not a path",
};
self.terminal.printError("{s}", .{msg});
return;
};
const path: []const u8, const mode: SaveMode = if (self.save_path) |saved| blk: {
if (filename) |name| {
if (!std.mem.eql(u8, saved, name)) {
self.terminal.printError("already saving to {s}; use /save without a filename to append to it", .{saved});
return;
}
}
break :blk .{ saved, .append };
} else blk: {
const path = filename orelse randomSaveFilename(arena) catch |err| {
self.terminal.printError("failed to choose save filename: {s}", .{@errorName(err)});
return;
};
const exists = fileExists(path) catch |err| {
self.terminal.printError("failed to inspect {s}: {s}", .{ path, @errorName(err) });
return;
};
const mode: SaveMode = if (exists)
self.promptSaveMode(path) orelse return
else
.replace;
break :blk .{ path, mode };
};
// `path` aliases either an arena-owned string (first save) or
// `self.save_path` (subsequent saves to the same destination); only
// the former needs to be persisted into agent-owned memory.
var new_save_path: ?[]u8 = if (self.save_path == null)
self.allocator.dupe(u8, path) catch |err| {
self.terminal.printError("failed to remember save destination {s}: {s}", .{ path, @errorName(err) });
return;
}
else
null;
defer if (new_save_path) |p| self.allocator.free(p);
self.writeSaveFile(path, mode) catch |err| {
self.terminal.printError("failed to save {s}: {s}", .{ path, @errorName(err) });
return;
};
if (new_save_path) |p| {
self.save_path = p;
new_save_path = null;
}
const saved_lines = self.save_buffer.lines;
self.save_buffer.reset();
self.terminal.printInfo("Saved {d} line(s) to {s}", .{ saved_lines, self.save_path.? });
}
fn parseSaveFilename(rest: []const u8) !?[]const u8 {
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
if (trimmed.len == 0) return null;
const name = if (trimmed[0] == '\'' or trimmed[0] == '"') blk: {
const quote = trimmed[0];
const end = std.mem.indexOfScalarPos(u8, trimmed, 1, quote) orelse return error.UnterminatedQuote;
if (std.mem.trim(u8, trimmed[end + 1 ..], &std.ascii.whitespace).len != 0) return error.TooManyArguments;
break :blk trimmed[1..end];
} else blk: {
if (std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) != null) return error.TooManyArguments;
break :blk trimmed;
};
if (name.len == 0) return error.EmptyFilename;
if (std.fs.path.isAbsolute(name)) return error.InvalidFilename;
if (std.mem.indexOfScalar(u8, name, '/') != null) return error.InvalidFilename;
if (std.mem.indexOfScalar(u8, name, '\\') != null) return error.InvalidFilename;
if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidFilename;
return name;
}
fn randomSaveFilename(arena: std.mem.Allocator) ![]const u8 {
for (0..100) |_| {
const n = std.crypto.random.int(u64);
const path = try std.fmt.allocPrint(arena, "session-{x}.lp", .{n});
if (!(try fileExists(path))) return path;
}
return error.NameCollision;
}
fn fileExists(path: []const u8) !bool {
std.fs.cwd().access(path, .{}) catch |err| switch (err) {
error.FileNotFound => return false,
else => return err,
};
return true;
}
fn promptSaveMode(self: *Agent, path: []const u8) ?SaveMode {
var header_buf: [256]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, "{s} already exists. Pick save mode:", .{path}) catch
"File already exists. Pick save mode:";
const labels: []const []const u8 = &.{ "replace", "append" };
const idx = Terminal.promptNumberedChoice(header, labels, null) catch {
self.terminal.printInfo("Save cancelled.", .{});
return null;
};
return if (idx == 0) .replace else .append;
}
fn writeSaveFile(self: *Agent, path: []const u8, mode: SaveMode) !void {
const content = self.save_buffer.bytes();
const file = try std.fs.cwd().createFile(path, .{ .truncate = mode == .replace });
defer file.close();
if (mode == .append) {
try file.seekFromEnd(0);
const pos = try file.getPos();
if (pos > 0 and content.len > 0) try file.writeAll("\n");
}
try file.writeAll(content);
}
fn recordSaveCommand(self: *Agent, cmd: Command) void {
self.save_buffer.record(cmd) catch |err| {
self.terminal.printError("save buffer disabled: {s}", .{@errorName(err)});
};
}
fn recordSaveComment(self: *Agent, comment: []const u8) void {
self.save_buffer.recordComment(comment) catch |err| {
self.terminal.printError("save buffer disabled: {s}", .{@errorName(err)});
};
}
fn helpLessThan(_: void, a: SlashCommand.Help, b: SlashCommand.Help) bool {
return std.mem.lessThan(u8, a.name, b.name);
}
@@ -597,6 +745,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
"/verbosity <low|medium|high> — set REPL agent verbosity (currently: {s}). Bare /verbosity prints the level.",
.{@tagName(self.terminal.verbosity)},
),
.save => self.terminal.printInfo(
"/save [filename.lp] — save recorded REPL actions to [filename.lp]. Without a filename, creates a random session-*.lp file in the current directory.",
.{},
),
}
return;
}
@@ -1145,7 +1297,9 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
if (result.cancelled) return self.drainCancellation(msg_baseline);
if (self.recorder) |*r| if (r.isActive()) {
const file_recorder: ?*Recorder = if (self.recorder) |*r| (if (r.isActive()) r else null) else null;
const record_to_memory = input.capture_for_save;
if (file_recorder != null or record_to_memory) {
// When the LLM tries multiple `extract` schemas in one turn, only the
// last successful one is the answer — earlier probes are noise.
var last_extract_idx: ?usize = null;
@@ -1163,15 +1317,19 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
const cmd = Command.fromToolCall(tool, args);
if (!cmd.isRecorded()) continue;
if (!recorded_any) {
if (input.record_comment) |c| r.recordComment(c);
if (input.record_comment) |c| {
if (file_recorder) |r| r.recordComment(c);
if (record_to_memory) self.recordSaveComment(c);
}
recorded_any = true;
}
r.record(cmd);
if (file_recorder) |r| r.record(cmd);
if (record_to_memory) self.recordSaveCommand(cmd);
}
if (!r.isActive()) {
if (file_recorder) |r| if (!r.isActive()) {
self.terminal.printError("recording disabled (write failed); see logs", .{});
}
};
};
}
// Dupe into `message_arena` — RunToolsResult arenas are deinited below.
const final_text: ?[]const u8 = blk: {

View File

@@ -44,13 +44,14 @@ pub const MetaCommand = struct {
/// Dispatched by `Agent.handleMeta` via an exhaustive switch so adding
/// a new meta command is a compile error until it's wired up there too.
const Tag = enum { help, quit, verbosity };
const Tag = enum { help, quit, verbosity, save };
};
pub const meta_commands = [_]MetaCommand{
.{ .tag = .help, .name = "help", .hint = "", .values = &.{}, .description = "Show help for a slash command, or list all when no name is given" },
.{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" },
.{ .tag = .verbosity, .name = "verbosity", .hint = "<low|medium|high>", .values = &.{ "low", "medium", "high" }, .description = "Set REPL agent verbosity; bare /verbosity prints the current level" },
.{ .tag = .save, .name = "save", .hint = "[filename.lp]", .values = &.{}, .description = "Save this REPL session as a PandaScript file" },
};
/// LLM-driven slash commands. Parsed via `script.Command.parse` (they're

View File

@@ -133,6 +133,71 @@ fn disable(self: *Recorder, err: anyerror) void {
}
}
/// In-memory recorder used by the REPL `/save` command. It intentionally
/// shares the same command filter/formatter/scrubber as file recording, but
/// leaves persistence timing to the caller.
pub const Memory = struct {
allocator: std.mem.Allocator,
lines: u32,
content: std.Io.Writer.Allocating,
buf: std.Io.Writer.Allocating,
arena: std.heap.ArenaAllocator,
pub fn init(allocator: std.mem.Allocator) Memory {
return .{
.allocator = allocator,
.lines = 0,
.content = .init(allocator),
.buf = .init(allocator),
.arena = .init(allocator),
};
}
pub fn deinit(self: *Memory) void {
self.content.deinit();
self.buf.deinit();
self.arena.deinit();
}
pub fn bytes(self: *Memory) []const u8 {
return self.content.written();
}
pub fn reset(self: *Memory) void {
self.lines = 0;
self.content.clearRetainingCapacity();
self.buf.clearRetainingCapacity();
_ = self.arena.reset(.retain_capacity);
}
pub fn record(self: *Memory, cmd: Command) !void {
if (!cmd.isRecorded()) return;
self.buf.clearRetainingCapacity();
try cmd.format(&self.buf.writer);
try self.buf.writer.writeByte('\n');
try self.appendScrubbed();
}
pub fn recordComment(self: *Memory, comment: []const u8) !void {
self.buf.clearRetainingCapacity();
var it = std.mem.splitScalar(u8, comment, '\n');
while (it.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, "\r");
try self.buf.writer.writeAll("# ");
try self.buf.writer.writeAll(trimmed);
try self.buf.writer.writeByte('\n');
}
try self.appendScrubbed();
}
fn appendScrubbed(self: *Memory) !void {
_ = self.arena.reset(.retain_capacity);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
try self.content.writer.writeAll(scrubbed);
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
}
};
// --- Tests ---
fn parseLine(arena: std.mem.Allocator, line: []const u8) Command {
@@ -402,3 +467,31 @@ test "record and parse: triple-quote round-trip" {
const parsed_val = parsed_cmd.tool_call.args.?.object.get("schema").?.string;
try std.testing.expectEqualStrings(original_val, parsed_val);
}
test "memory recorder mirrors file recorder filtering" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var memory: Memory = .init(std.testing.allocator);
defer memory.deinit();
try memory.record(parseLine(aa, "/goto https://example.com"));
try memory.record(parseLine(aa, "/tree"));
try memory.record(parseLine(aa, "/click selector='Login'"));
try memory.recordComment("search for login");
try std.testing.expectEqualStrings(
"/goto 'https://example.com'\n/click selector='Login'\n# search for login\n",
memory.bytes(),
);
try std.testing.expectEqual(@as(u32, 3), memory.lines);
memory.reset();
try std.testing.expectEqualStrings("", memory.bytes());
try std.testing.expectEqual(@as(u32, 0), memory.lines);
try memory.record(parseLine(aa, "/scroll y=200"));
try std.testing.expectEqualStrings("/scroll y=200\n", memory.bytes());
try std.testing.expectEqual(@as(u32, 1), memory.lines);
}