agent: replace --save with --interactive flag

Replays scripts then enters REPL to append new commands to the file.
The --save flag is removed in favor of this new interactive mode.
This commit is contained in:
Adrià Arrufat
2026-04-10 20:57:22 +02:00
parent 3a9573e1ae
commit 4a63bbf233
3 changed files with 104 additions and 24 deletions

View File

@@ -248,8 +248,8 @@ pub const Agent = struct {
base_url: ?[:0]const u8 = null,
system_prompt: ?[:0]const u8 = null,
script_file: ?[]const u8 = null,
save: bool = false,
self_heal: bool = false,
interactive: bool = false,
};
pub const DumpFormat = enum {
@@ -541,12 +541,15 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Example: {0s} agent --provider anthropic --model claude-haiku-4-5-20251001
\\Example: {0s} agent --provider ollama --model gemma4
\\Example: {0s} agent script.panda (replay a recorded script)
\\Example: {0s} agent --save script.panda (record a new script)
\\Example: {0s} agent -i script.panda (replay then drop into REPL,
\\ appending new commands to the file)
\\
\\Arguments:
\\[script_file] Optional path to a .panda script.
\\ Without --save: replays the script (no LLM calls).
\\ With --save: records the agent session into this file.
\\ Without -i: replays the script (no LLM calls).
\\ With -i: replays if present, then enters the REPL and
\\ appends any new commands to the file (creating it if
\\ it does not yet exist).
\\
\\Options:
\\--provider The AI provider: anthropic, openai, gemini, or ollama.
@@ -561,12 +564,15 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\--system-prompt Override the default system prompt.
\\
\\--save Record the session's commands into the given script_file.
\\ Requires a positional script_file argument.
\\
\\--self-heal On tool errors, ask the model to recover by retrying
\\ with fresh page state instead of aborting.
\\
\\-i, --interactive
\\ After replaying the positional script (if any), drop
\\ into the REPL with the browser state preserved. When
\\ a positional script is present, any new commands
\\ entered in the REPL are appended to that file.
\\
\\The API key is read from the environment:
\\ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY.
\\Ollama does not require an API key.
@@ -989,13 +995,13 @@ fn parseAgentArgs(
continue;
}
if (std.mem.eql(u8, "--save", opt)) {
result.save = true;
if (std.mem.eql(u8, "--self-heal", opt) or std.mem.eql(u8, "--self_heal", opt)) {
result.self_heal = true;
continue;
}
if (std.mem.eql(u8, "--self-heal", opt) or std.mem.eql(u8, "--self_heal", opt)) {
result.self_heal = true;
if (std.mem.eql(u8, "-i", opt) or std.mem.eql(u8, "--interactive", opt)) {
result.interactive = true;
continue;
}

View File

@@ -77,12 +77,14 @@ model: []const u8,
system_prompt: []const u8,
script_file: ?[]const u8,
self_heal: bool,
interactive: bool,
pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self {
const is_script_mode = opts.script_file != null and !opts.save;
// Pure replay (positional script, no -i) is the only mode that skips the REPL
// and therefore doesn't need an API key.
const will_repl = opts.interactive or opts.script_file == null;
// API key is only required for REPL mode and self-healing
const api_key: ?[:0]const u8 = getEnvApiKey(opts.provider) orelse if (!is_script_mode) {
const api_key: ?[:0]const u8 = getEnvApiKey(opts.provider) orelse if (will_repl) {
log.fatal(.app, "missing API key", .{
.hint = "Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY",
});
@@ -114,7 +116,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self
// Persist REPL history in a cwd-relative `.lp-history`. Skipped in pure
// replay mode (no REPL is opened).
const history_path: ?[:0]const u8 = if (is_script_mode) null else ".lp-history";
const history_path: ?[:0]const u8 = if (will_repl) ".lp-history" else null;
// Record REPL commands into the positional script file only when both
// are present — `-i <file>` means "replay then grow this file".
const recorder_path: ?[]const u8 = if (opts.interactive) opts.script_file else null;
self.* = .{
.allocator = allocator,
@@ -122,14 +128,15 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self
.tool_executor = tool_executor,
.terminal = Terminal.init(history_path),
.cmd_executor = undefined,
.recorder = Recorder.init(if (opts.save) opts.script_file else null),
.recorder = Recorder.init(recorder_path),
.messages = .empty,
.message_arena = std.heap.ArenaAllocator.init(allocator),
.tools = tools,
.model = opts.model orelse defaultModel(opts.provider),
.system_prompt = opts.system_prompt orelse default_system_prompt,
.script_file = if (!opts.save) opts.script_file else null,
.script_file = opts.script_file,
.self_heal = opts.self_heal,
.interactive = opts.interactive,
};
self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal);
@@ -153,10 +160,13 @@ pub fn deinit(self: *Self) void {
self.allocator.destroy(self);
}
/// Returns true on success, false if a script command failed.
/// Returns true on success. In interactive mode the REPL always runs after
/// the (optional) replay phase and the function always returns true; in pure
/// replay mode it returns whatever `runScript` returned.
pub fn run(self: *Self) bool {
if (self.script_file) |path| {
return self.runScript(path);
const script_ok = self.runScript(path);
if (!self.interactive) return script_ok;
}
self.runRepl();
return true;

View File

@@ -6,14 +6,24 @@ const Self = @This();
file: ?std.fs.File,
needs_separator: bool,
/// Open `path` for append. The file is created if missing; if it already has
/// content, a leading newline is written so the appended block starts on a
/// fresh line. A null path disables recording (no-op).
pub fn init(path: ?[]const u8) Self {
const file: ?std.fs.File = if (path) |p|
std.fs.cwd().createFile(p, .{}) catch |err| blk: {
const file: ?std.fs.File = if (path) |p| blk: {
const f = std.fs.cwd().createFile(p, .{ .truncate = false }) catch |err| {
std.debug.print("Warning: could not open recording file: {s}\n", .{@errorName(err)});
break :blk null;
}
else
null;
};
f.seekFromEnd(0) catch |err| {
std.debug.print("Warning: could not seek in recording file: {s}\n", .{@errorName(err)});
f.close();
break :blk null;
};
const pos = f.getPos() catch 0;
if (pos > 0) _ = f.write("\n") catch {};
break :blk f;
} else null;
return .{ .file = file, .needs_separator = false };
}
@@ -117,3 +127,57 @@ test "recorder with null file is no-op" {
recorder.recordComment("# test");
recorder.deinit();
}
test "init appends to an existing file without truncating" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Seed a file with a prior line.
{
const seed = tmp.dir.createFile("script.panda", .{}) catch unreachable;
defer seed.close();
_ = seed.writeAll("GOTO https://example.com\n") catch unreachable;
}
// Resolve absolute path for Recorder.init (which uses std.fs.cwd()).
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const abs_path = tmp.dir.realpath("script.panda", &path_buf) catch unreachable;
var recorder = init(abs_path);
defer recorder.deinit();
recorder.record(Command.parse("CLICK 'Login'"));
// Read back.
const file = tmp.dir.openFile("script.panda", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expect(std.mem.indexOf(u8, content, "GOTO https://example.com\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "CLICK 'Login'\n") != null);
// The prior line must precede the appended line.
const prior = std.mem.indexOf(u8, content, "GOTO").?;
const appended = std.mem.indexOf(u8, content, "CLICK").?;
try std.testing.expect(prior < appended);
}
test "init creates the file if missing" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = tmp.dir.realpath(".", &path_buf) catch unreachable;
var full_buf: [std.fs.max_path_bytes]u8 = undefined;
const abs_path = std.fmt.bufPrint(&full_buf, "{s}/fresh.panda", .{dir_path}) catch unreachable;
var recorder = init(abs_path);
defer recorder.deinit();
recorder.record(Command.parse("GOTO https://example.com"));
const file = tmp.dir.openFile("fresh.panda", .{}) catch unreachable;
defer file.close();
var buf: [128]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings("GOTO https://example.com\n", buf[0..n]);
}