diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 63ad24f3..4af23d9a 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -500,78 +500,19 @@ fn runScript(self: *Self, path: []const u8) bool { }, else => { self.terminal.printInfoFmt("[{d}] {s}", .{ entry.line_num, entry.raw_line }); - - var cmd_arena: std.heap.ArenaAllocator = .init(self.allocator); - defer cmd_arena.deinit(); - - const pre_state: ?Verifier.PreState = if (self.self_heal) - self.verifier.capturePreState(cmd_arena.allocator(), entry.command) - else - null; - - const result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command); - self.cmd_executor.printResult(entry.command, result); - - const verification = if (!result.failed and pre_state != null) - self.verifier.verify(cmd_arena.allocator(), entry.command, pre_state.?) - else - Verifier.VerifyResult{ .result = .passed }; - - const effective_failed = result.failed or verification.result == .failed; - - if (effective_failed) { - if (self.self_heal and self.ai_client != null) { - // Retry with wait before LLM escalation for - // verification failures (not hard failures). - if (!result.failed and isRetryable(entry.command)) { - var retried = false; - for (0..3) |i| { - std.Thread.sleep((500 + i * 250) * std.time.ns_per_ms); - self.terminal.printInfo("Retrying command..."); - const retry_pre = self.verifier.capturePreState(cmd_arena.allocator(), entry.command); - const retry_result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command); - if (!retry_result.failed) { - if (self.verifier.verify(cmd_arena.allocator(), entry.command, retry_pre).result != .failed) { - self.cmd_executor.printResult(entry.command, retry_result); - retried = true; - break; - } - } - } - if (retried) continue; - } - - const msg = if (result.failed) - "Command failed, attempting self-healing..." - else - "Command succeeded but verification failed, attempting self-healing..."; - self.terminal.printInfo(msg); - - if (self.attemptSelfHeal(sa, entry.raw_line, verification.reason, last_comment)) |healed_cmds| { - const replacement = formatReplacement(sa, entry.raw_span, entry.raw_line, healed_cmds) catch |err| { - self.terminal.printErrorFmt( - "line {d}: failed to record heal: {s} (script left unchanged)", - .{ entry.line_num, @errorName(err) }, - ); - self.flushReplacements(path, content, replacements.items); - return false; - }; - replacements.append(sa, replacement) catch |err| { - self.terminal.printErrorFmt( - "line {d}: out of memory recording heal: {s} (script left unchanged)", - .{ entry.line_num, @errorName(err) }, - ); - return false; - }; - continue; - } - } - self.terminal.printErrorFmt("line {d}: command failed: {s}", .{ - entry.line_num, - entry.raw_line, - }); - self.flushReplacements(path, content, replacements.items); - return false; + switch (self.runActionEntry(sa, entry, last_comment)) { + .ok => {}, + .healed => |r| replacements.append(sa, r) catch |err| { + self.terminal.printErrorFmt( + "line {d}: out of memory recording heal: {s} (script left unchanged)", + .{ entry.line_num, @errorName(err) }, + ); + return false; + }, + .fail => { + self.flushReplacements(path, content, replacements.items); + return false; + }, } }, } @@ -582,6 +523,84 @@ fn runScript(self: *Self, path: []const u8) bool { return true; } +const ActionOutcome = union(enum) { + /// Command succeeded (possibly after retry). + ok, + /// Command was rewritten by self-heal — caller appends to replacements. + healed: Replacement, + /// Unrecoverable failure; the per-line error has already been printed. + fail, +}; + +/// Execute one action-style script entry, including post-execution +/// verification, transient-failure retry, and LLM self-heal escalation. +fn runActionEntry(self: *Self, sa: std.mem.Allocator, entry: Command.ScriptIterator.Entry, last_comment: ?[]const u8) ActionOutcome { + var cmd_arena: std.heap.ArenaAllocator = .init(self.allocator); + defer cmd_arena.deinit(); + const ca = cmd_arena.allocator(); + + const pre_state: ?Verifier.PreState = if (self.self_heal) + self.verifier.capturePreState(ca, entry.command) + else + null; + + const result = self.cmd_executor.executeWithResult(ca, entry.command); + self.cmd_executor.printResult(entry.command, result); + + const verification = if (!result.failed and pre_state != null) + self.verifier.verify(ca, entry.command, pre_state.?) + else + Verifier.VerifyResult{ .result = .passed }; + + if (!result.failed and verification.result != .failed) return .ok; + + if (self.self_heal and self.ai_client != null) { + // Verification-only failures often resolve with a brief wait + // (animations, lazy-load); skip the LLM round-trip when they do. + if (!result.failed and isRetryable(entry.command) and self.retryCommand(ca, entry.command)) { + return .ok; + } + + const msg = if (result.failed) + "Command failed, attempting self-healing..." + else + "Command succeeded but verification failed, attempting self-healing..."; + self.terminal.printInfo(msg); + + if (self.attemptSelfHeal(sa, entry.raw_line, verification.reason, last_comment)) |healed_cmds| { + const replacement = formatReplacement(sa, entry.raw_span, entry.raw_line, healed_cmds) catch |err| { + self.terminal.printErrorFmt( + "line {d}: failed to record heal: {s} (script left unchanged)", + .{ entry.line_num, @errorName(err) }, + ); + return .fail; + }; + return .{ .healed = replacement }; + } + } + self.terminal.printErrorFmt("line {d}: command failed: {s}", .{ + entry.line_num, + entry.raw_line, + }); + return .fail; +} + +/// Re-run a verification-failed command with bounded backoff. Returns true +/// once both execution and verification pass, false after 3 attempts. +fn retryCommand(self: *Self, ca: std.mem.Allocator, cmd: Command.Command) bool { + for (0..3) |i| { + std.Thread.sleep((500 + i * 250) * std.time.ns_per_ms); + self.terminal.printInfo("Retrying command..."); + const retry_pre = self.verifier.capturePreState(ca, cmd); + const retry_result = self.cmd_executor.executeWithResult(ca, cmd); + if (retry_result.failed) continue; + if (self.verifier.verify(ca, cmd, retry_pre).result == .failed) continue; + self.cmd_executor.printResult(cmd, retry_result); + return true; + } + return false; +} + fn formatReplacement(arena: std.mem.Allocator, original_span: []const u8, raw_line: []const u8, cmds: []const Command.Command) !Replacement { std.debug.assert(cmds.len > 0); var aw: std.Io.Writer.Allocating = .init(arena); diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 02e2e974..e0441867 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -24,18 +24,17 @@ fn atLeast(level: Verbosity, min: Verbosity) bool { history_path: ?[:0]const u8, verbosity: Verbosity, -/// True when the user can type at us. Tool results are always shown -/// here, regardless of verbosity, because in the REPL every tool call -/// is something the user just asked for (a slash command, or natural -/// language they sent to the LLM) — suppressing the body would leave -/// them blind. The `--verbosity` dial only matters in non-interactive -/// runs (one-shot `--task`, scripts, `--mcp`), where LLM tool traces -/// are noise. -is_repl: bool, -/// Scratch arena for the REPL pretty-printer's `std.json.Value` tree. -/// Reset on every `printToolResult` call so memory is bounded by the -/// largest single tool output, not the session length — important when -/// the LLM loop fires many tool calls per turn. +/// Non-null when the user can type at us. Tool calls and results are +/// always shown in REPL mode regardless of verbosity, because every +/// call is something the user just asked for (a slash command, or +/// natural language they sent to the LLM) — suppressing the body +/// would leave them blind. The `--verbosity` dial only matters in +/// non-interactive runs (one-shot `--task`, scripts, `--mcp`), where +/// LLM tool traces are noise. +/// +/// Doubles as the scratch arena for the pretty-printer's +/// `std.json.Value` tree. Reset on every `printToolResult` call so +/// memory is bounded by the largest single tool output. repl_arena: ?std.heap.ArenaAllocator, const CommandInfo = struct { name: [:0]const u8, hint: [:0]const u8 }; @@ -97,11 +96,14 @@ pub fn init(allocator: std.mem.Allocator, history_path: ?[:0]const u8, verbosity return .{ .history_path = history_path, .verbosity = verbosity, - .is_repl = is_repl, .repl_arena = if (is_repl) std.heap.ArenaAllocator.init(allocator) else null, }; } +fn isRepl(self: *const Self) bool { + return self.repl_arena != null; +} + pub fn deinit(self: *Self) void { if (self.repl_arena) |*a| a.deinit(); } @@ -419,7 +421,7 @@ pub fn printActionResult(self: *Self, text: []const u8) void { } pub fn printToolCall(self: *Self, name: []const u8, args: []const u8) void { - if (!self.is_repl and !atLeast(self.verbosity, .normal)) return; + if (!self.isRepl() and !atLeast(self.verbosity, .normal)) return; std.debug.print("\n{s}{s}[tool: {s}]{s} {s}\n", .{ ansi_dim, ansi_cyan, name, ansi_reset, args }); } @@ -433,7 +435,7 @@ pub fn printToolCall(self: *Self, name: []const u8, args: []const u8) void { const max_result_display_len = 2000; pub fn printToolResult(self: *Self, name: []const u8, result: []const u8) void { - if (!self.is_repl and !atLeast(self.verbosity, .verbose)) return; + if (!self.isRepl() and !atLeast(self.verbosity, .verbose)) return; if (self.repl_arena) |*a| { defer _ = a.reset(.retain_capacity); printRepl(a.allocator(), name, result);