agent: modularize script runner and simplify terminal state

This commit is contained in:
Adrià Arrufat
2026-05-07 09:35:08 +02:00
parent 92a0f0e290
commit 87d0bc95dc
2 changed files with 108 additions and 87 deletions

View File

@@ -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);

View File

@@ -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);