agent: record slash commands as tool calls

Mirrors user-typed slash commands into the message history as synthetic
tool calls. This ensures the LLM conversation remains in sync and the
next prompt can see the action's result.
This commit is contained in:
Adrià Arrufat
2026-05-23 11:54:48 +02:00
parent 0670823905
commit 4c06fe482f
2 changed files with 64 additions and 0 deletions

View File

@@ -158,6 +158,7 @@ interactive: bool,
one_shot_task: ?[]const u8,
one_shot_attachments: ?[]const []const u8,
cancel_requested: std.atomic.Value(bool) = .init(false),
synthetic_tool_call_id: u32 = 0,
pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent {
if (opts.task != null and opts.script_file != null) {
@@ -498,6 +499,9 @@ fn runRepl(self: *Agent) void {
self.terminal.endTool();
self.printCommandResult(cmd, result);
if (self.recorder) |*r| r.record(cmd);
self.recordSlashToolCall(tc.name(), tc.args, result) catch |err| {
self.terminal.printWarning("LLM conversation out of sync (/{s}: {s}); next prompt may not see this action", .{ tc.name(), @errorName(err) });
};
},
}
}
@@ -825,6 +829,53 @@ fn ensureSystemPrompt(self: *Agent) !void {
}
}
/// Mirror a user-typed slash command into `self.messages` as if the LLM
/// had called the tool itself, so the next natural-language turn sees
/// the same conversation shape either way.
fn recordSlashToolCall(
self: *Agent,
tool_name: []const u8,
args: ?std.json.Value,
result: browser_tools.ToolResult,
) !void {
if (self.ai_client == null) return;
try self.ensureSystemPrompt();
const ma = self.message_arena.allocator();
self.synthetic_tool_call_id += 1;
const tool_calls = try ma.alloc(zenai.provider.ToolCall, 1);
tool_calls[0] = .{
.id = try std.fmt.allocPrint(ma, "lp-slash-{d}", .{self.synthetic_tool_call_id}),
.name = try ma.dupe(u8, tool_name),
.arguments = if (args) |v| try zenai.json.dupeValue(ma, v) else null,
};
// capToolOutput returns its input unchanged under the cap; dupe so
// content doesn't alias the caller's per-iteration arena.
const capped = capToolOutput(ma, result.text);
const content = if (capped.ptr == result.text.ptr) try ma.dupe(u8, capped) else capped;
const tool_results = try ma.alloc(zenai.provider.ToolResult, 1);
tool_results[0] = .{
.id = try ma.dupe(u8, tool_calls[0].id),
.name = try ma.dupe(u8, tool_calls[0].name),
.content = content,
.is_error = result.is_error,
};
const baseline = self.messages.items.len;
errdefer self.messages.shrinkRetainingCapacity(baseline);
try self.messages.append(self.allocator, .{
.role = .assistant,
.tool_calls = tool_calls,
});
try self.messages.append(self.allocator, .{
.role = .tool,
.tool_results = tool_results,
});
}
const prune_high = 30;
const prune_keep = 20;

View File

@@ -759,6 +759,19 @@ pub fn printError(self: *Terminal, comptime fmt: []const u8, args: anytype) void
std.debug.print("{s}{s}Error: " ++ fmt ++ "{s}\n", .{ ansi.bold, ansi.red } ++ args ++ .{ansi.reset});
}
pub fn printWarning(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
if (self.repl_arena) |*a| {
defer _ = a.reset(.retain_capacity);
var aw: std.Io.Writer.Allocating = .init(a.allocator());
aw.writer.print("{s}●{s} " ++ fmt ++ "\n", .{ ansi.yellow, ansi.reset } ++ args) catch return;
const bytes = aw.written();
if (self.spinner.emitAbove(bytes)) return;
_ = std.posix.write(std.posix.STDERR_FILENO, bytes) catch {};
return;
}
std.debug.print("{s}{s}Warning: " ++ fmt ++ "{s}\n", .{ ansi.bold, ansi.yellow } ++ args ++ .{ansi.reset});
}
pub fn printInfo(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
if (!self.isRepl() and !atLeast(self.verbosity, .medium)) return;
std.debug.print(fmt ++ "\n", args);