diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 6d015420..2be981cb 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -182,15 +182,13 @@ fn runRepl(self: *Self) void { .exit => break, .comment => continue, .login => { - self.recorder.recordComment(line); - self.processUserMessage(login_prompt) catch |err| { + self.processUserMessage(login_prompt, line) catch |err| { const msg = std.fmt.allocPrint(self.allocator, "LOGIN failed: {s}", .{@errorName(err)}) catch "LOGIN failed"; self.terminal.printError(msg); }; }, .accept_cookies => { - self.recorder.recordComment(line); - self.processUserMessage(accept_cookies_prompt) catch |err| { + self.processUserMessage(accept_cookies_prompt, line) catch |err| { const msg = std.fmt.allocPrint(self.allocator, "ACCEPT_COOKIES failed: {s}", .{@errorName(err)}) catch "ACCEPT_COOKIES failed"; self.terminal.printError(msg); }; @@ -199,8 +197,7 @@ fn runRepl(self: *Self) void { // "quit" as a convenience alias if (std.mem.eql(u8, line, "quit")) break; - self.recorder.recordComment(line); - self.processUserMessage(line) catch |err| { + self.processUserMessage(line, line) catch |err| { const msg = std.fmt.allocPrint(self.allocator, "Request failed: {s}", .{@errorName(err)}) catch "Request failed"; self.terminal.printError(msg); }; @@ -269,7 +266,7 @@ fn runScript(self: *Self, path: []const u8) void { return; } const prompt = if (entry.command == .login) login_prompt else accept_cookies_prompt; - self.processUserMessage(prompt) catch |err| { + self.processUserMessage(prompt, "") catch |err| { const msg = std.fmt.allocPrint(self.allocator, "line {d}: {s} failed: {s}", .{ entry.line_num, entry.raw_line, @@ -329,11 +326,11 @@ fn attemptSelfHeal(self: *Self, intent: ?[]const u8, failed_command: []const u8) self_heal_prompt_page_state, }) catch return false; - self.processUserMessage(prompt) catch return false; + self.processUserMessage(prompt, "") catch return false; return true; } -fn processUserMessage(self: *Self, user_input: []const u8) !void { +fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []const u8) !void { const ma = self.message_arena.allocator(); // Add system prompt as first message if this is the first user message @@ -369,10 +366,17 @@ fn processUserMessage(self: *Self, user_input: []const u8) !void { }; defer result.deinit(); - // Record tool calls as Pandascript + // Record tool calls as Pandascript (only if they produce commands) + var recorded_any = false; for (result.tool_calls_made) |tc| { if (!std.mem.startsWith(u8, tc.result, "Error:")) { - self.recordToolCall(ma, tc.name, tc.arguments); + if (toolCallToCommand(ma, tc.name, tc.arguments)) |cmd| { + if (!recorded_any) { + if (record_comment.len > 0) self.recorder.recordComment(record_comment); + recorded_any = true; + } + self.recorder.record(cmd); + } } } @@ -393,15 +397,15 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co return tool_result; } -/// Convert a tool call (name + JSON arguments) into a Pandascript command and record it. -fn recordToolCall(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments: []const u8) void { - const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments, .{}) catch return; +/// Convert a tool call (name + JSON arguments) into a Pandascript command. +fn toolCallToCommand(arena: std.mem.Allocator, tool_name: []const u8, arguments: []const u8) ?Command.Command { + const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments, .{}) catch return null; const obj = switch (parsed.value) { .object => |o| o, - else => return, + else => return null, }; - const cmd: ?Command.Command = if (std.mem.eql(u8, tool_name, "goto") or std.mem.eql(u8, tool_name, "navigate")) blk: { + return if (std.mem.eql(u8, tool_name, "goto")) blk: { break :blk switch (obj.get("url") orelse break :blk null) { .string => |s| .{ .goto = s }, else => null, @@ -425,16 +429,12 @@ fn recordToolCall(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, else => break :blk null, }; break :blk .{ .type_cmd = .{ .selector = sel, .value = val } }; - } else if (std.mem.eql(u8, tool_name, "evaluate") or std.mem.eql(u8, tool_name, "eval")) blk: { + } else if (std.mem.eql(u8, tool_name, "eval")) blk: { break :blk switch (obj.get("script") orelse break :blk null) { .string => |s| .{ .eval_js = s }, else => null, }; } else null; - - if (cmd) |c| { - self.recorder.record(c); - } } fn getEnvApiKey(provider_type: Config.AiProvider) ?[:0]const u8 { diff --git a/src/agent/Recorder.zig b/src/agent/Recorder.zig index 261e3eab..c734b56a 100644 --- a/src/agent/Recorder.zig +++ b/src/agent/Recorder.zig @@ -4,23 +4,18 @@ const Command = @import("Command.zig"); const Self = @This(); file: ?std.fs.File, +needs_separator: bool, -/// Commands that are read-only / ephemeral and should NOT be recorded. pub fn init(path: ?[]const u8) Self { const file: ?std.fs.File = if (path) |p| - std.fs.cwd().createFile(p, .{ .truncate = false }) catch |err| blk: { + std.fs.cwd().createFile(p, .{}) catch |err| blk: { std.debug.print("Warning: could not open recording file: {s}\n", .{@errorName(err)}); break :blk null; } else null; - // Seek to end for appending - if (file) |f| { - f.seekFromEnd(0) catch {}; - } - - return .{ .file = file }; + return .{ .file = file, .needs_separator = false }; } pub fn deinit(self: *Self) void { @@ -38,14 +33,21 @@ pub fn record(self: *Self, cmd: Command.Command) void { const writer = &file_writer.interface; writer.print("{f}\n", .{cmd}) catch return; writer.flush() catch return; + self.needs_separator = true; } /// Record a comment line (e.g. user's natural language input). pub fn recordComment(self: *Self, comment: []const u8) void { const f = self.file orelse return; - f.writeAll("\n# ") catch return; - f.writeAll(comment) catch return; - f.writeAll("\n") catch return; + var buf: [4096]u8 = undefined; + var file_writer = f.writerStreaming(&buf); + const writer = &file_writer.interface; + if (self.needs_separator) writer.writeByte('\n') catch return; + self.needs_separator = true; + writer.writeAll("# ") catch return; + writer.writeAll(comment) catch return; + writer.writeByte('\n') catch return; + writer.flush() catch return; } // --- Tests --- @@ -56,7 +58,7 @@ test "record writes state-mutating commands" { const file = tmp.dir.createFile("test.panda", .{ .read = true }) catch unreachable; - var recorder = Self{ .file = file }; + var recorder = Self{ .file = file, .needs_separator = false }; defer recorder.deinit(); recorder.record(Command.parse("GOTO https://example.com")); @@ -89,7 +91,7 @@ test "record skips empty and comment lines" { const file = tmp.dir.createFile("test2.panda", .{ .read = true }) catch unreachable; - var recorder = Self{ .file = file }; + var recorder = Self{ .file = file, .needs_separator = false }; defer recorder.deinit(); recorder.record(Command.parse("")); @@ -106,7 +108,7 @@ test "record skips empty and comment lines" { } test "recorder with null file is no-op" { - var recorder = Self{ .file = null }; + var recorder = Self{ .file = null, .needs_separator = false }; recorder.record(Command.parse("GOTO https://example.com")); recorder.recordComment("# test"); recorder.deinit();