From 4a63bbf233ba6b8a9697ffeab7d5ff3385b17f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 10 Apr 2026 20:57:22 +0200 Subject: [PATCH] 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. --- src/Config.zig | 28 +++++++++------- src/agent/Agent.zig | 26 ++++++++++----- src/agent/Recorder.zig | 74 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 08db7c7e..e2769c94 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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; } diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 5b7114cd..75629abd 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -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 ` 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; diff --git a/src/agent/Recorder.zig b/src/agent/Recorder.zig index c2badb78..d34cb271 100644 --- a/src/agent/Recorder.zig +++ b/src/agent/Recorder.zig @@ -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]); +}