mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 17:46:32 -04:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user