Repository navigation
Expand file tree
/
Copy pathplan.zig
More file actions
155 lines (147 loc) · 7.98 KB
/
Copy pathplan.zig
File metadata and controls
155 lines (147 loc) · 7.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
const std = @import("std");
const plan_mod = @import("../state/plan.zig");
const sanitize = @import("../util/terminal/sanitize.zig");
const Agent = @import("../agent/mod.zig").Agent;
const tool = @import("tool.zig");
pub const spec: tool.Tool = .{
.name = "plan",
.description = "Read or update the plan for the work in progress: with no arguments it returns the complete plan, with 'steps' it replaces the whole plan (an empty array clears it), and with 'index' plus 'status' or 'note' it updates one step. Successful updates return only a brief change acknowledgement, not the checklist; call with no arguments to read it. The plan belongs to the session — it is saved with it and restored when it is resumed — so it, not the message history, is the map of long work.",
.parameters_json =
\\{"type":"object","properties":{"steps":{"type":"array","description":"The complete ordered plan, replacing the current one: one short line per step that can be verified on its own. At most 50 steps.","maxItems":50,"items":{"type":"object","properties":{"title":{"type":"string","description":"One line: what this step accomplishes"},"status":{"type":"string","enum":["pending","in_progress","completed","blocked"],"description":"Defaults to pending; at most one step is in_progress"},"note":{"type":"string","description":"Optional one-line reason kept under the step, for example why it is blocked"}},"required":["title"]}},"index":{"type":"integer","minimum":1,"description":"1-based step to update, when 'steps' is omitted"},"status":{"type":"string","enum":["pending","in_progress","completed","blocked"],"description":"New status for that step"},"note":{"type":"string","description":"New note for that step; an empty string removes the old one"}},"required":[]}
,
.label = "plan…",
.summary = writeSummary,
.run = run,
};
pub const ArgumentStep = struct {
title: []const u8,
status: ?[]const u8 = null,
note: ?[]const u8 = null,
};
pub const Arguments = struct {
steps: ?[]const ArgumentStep = null,
index: ?usize = null,
status: ?[]const u8 = null,
note: ?[]const u8 = null,
};
fn writeSummary(ctx: tool.Context, w: *std.Io.Writer, arguments: []const u8) !bool {
return writeCallSummary(w, ctx.allocator, arguments);
}
pub fn writeCallSummary(w: *std.Io.Writer, allocator: std.mem.Allocator, arguments_json: []const u8) !bool {
const parsed = std.json.parseFromSlice(Arguments, allocator, arguments_json, .{}) catch return false;
defer parsed.deinit();
const args = parsed.value;
if (args.steps) |steps| {
try w.print("{d} steps", .{steps.len});
for (steps, 0..) |step, i| {
if (i == 3) {
try w.writeAll(", …");
break;
}
try w.writeAll(if (i == 0) ": " else ", ");
try sanitize.writeOnOneLine(w, step.title);
}
return true;
}
if (args.index) |index| {
try w.print("step {d}", .{index});
if (args.status) |status| {
try w.writeAll(" → ");
try sanitize.writeOnOneLine(w, status);
}
if (args.note) |note| {
try w.writeAll(" · ");
try sanitize.writeOnOneLine(w, note);
}
return true;
}
try w.writeAll("read");
return true;
}
pub fn describeChange(plan: *const plan_mod.Plan, allocator: std.mem.Allocator, index: ?usize, previous_status: ?plan_mod.Status, note_changed: bool) ![]u8 {
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
if (index) |number| {
if (number == 0 or number > plan.steps.items.len) return error.NoSuchStep;
const status = plan.steps.items[number - 1].status;
if (previous_status) |previous| {
try aw.writer.print("Step {d}: {s} -> {s}", .{ number, @tagName(previous), @tagName(status) });
} else {
try aw.writer.print("Step {d}: {s}", .{ number, @tagName(status) });
}
if (note_changed) try aw.writer.writeAll("; note updated");
try aw.writer.writeAll(".\n");
} else if (plan.steps.items.len == 0) {
try aw.writer.writeAll("Plan cleared.\n");
} else {
const progress = plan.tally();
try aw.writer.print("Plan saved: {d} steps, {d} completed, {d} blocked.\n", .{ progress.total, progress.done, progress.blocked });
}
return aw.toOwnedSlice();
}
fn run(ctx: tool.Context, args: []const u8) !tool.ToolResult {
const agent = ctx.agent orelse return ctx.refuse("Error: this tool requires an agent context.");
return execute(agent, args);
}
/// Changes are persisted by the agent's end-of-batch checkpoint, never here:
/// a mid-batch snapshot would contain tool calls without their results.
pub fn execute(self: *Agent, arguments: []const u8) !tool.ToolResult {
const parsed = std.json.parseFromSlice(Arguments, self.allocator, arguments, .{}) catch {
return .{
.output = try self.allocator.dupe(u8, "Error: expected an object with an optional 'steps' array, or 'index' with 'status' or 'note'."),
.is_error = true,
};
};
defer parsed.deinit();
const args = parsed.value;
if (args.steps == null and args.index == null) {
if (args.status != null or args.note != null) return .{
.output = try self.allocator.dupe(u8, "Error: 'status' and 'note' change the step named by 'index'; add the index, or send 'steps' to replace the plan."),
.is_error = true,
};
return .{ .output = try self.plan.describe(self.allocator), .is_error = false };
}
if (args.steps != null and args.index != null) return .{
.output = try self.allocator.dupe(u8, "Error: send either 'steps' or 'index', not both."),
.is_error = true,
};
var previous_status: ?plan_mod.Status = null;
if (args.steps) |raw_steps| {
var converted: std.ArrayList(plan_mod.NewStep) = .empty;
defer converted.deinit(self.allocator);
try converted.ensureTotalCapacity(self.allocator, raw_steps.len);
for (raw_steps) |step| {
const status = if (step.status) |label| plan_mod.Status.parse(label) orelse return .{
.output = try std.fmt.allocPrint(self.allocator, "Error: step status '{s}' must be pending, in_progress, completed or blocked.", .{label}),
.is_error = true,
} else null;
converted.appendAssumeCapacity(.{ .title = step.title, .status = status, .note = step.note });
}
self.plan.setSteps(converted.items) catch |err| return .{
.output = try std.fmt.allocPrint(self.allocator, "The plan was not updated: {s}", .{@errorName(err)}),
.is_error = true,
};
} else {
const status = if (args.status) |label| plan_mod.Status.parse(label) orelse return .{
.output = try std.fmt.allocPrint(self.allocator, "Error: status '{s}' must be pending, in_progress, completed or blocked.", .{label}),
.is_error = true,
} else null;
if (status == null and args.note == null) return .{
.output = try self.allocator.dupe(u8, "Error: 'index' needs a 'status' or a 'note' to change."),
.is_error = true,
};
if (args.index.? > 0 and args.index.? <= self.plan.steps.items.len)
previous_status = self.plan.steps.items[args.index.? - 1].status;
self.plan.update(args.index.?, status, args.note) catch |err| switch (err) {
error.NoSuchStep => return .{
.output = try std.fmt.allocPrint(self.allocator, "Error: there is no step {d}; the plan has {d}.", .{ args.index.?, self.plan.steps.items.len }),
.is_error = true,
},
else => return .{
.output = try std.fmt.allocPrint(self.allocator, "The plan was not updated: {s}", .{@errorName(err)}),
.is_error = true,
},
};
}
return .{ .output = try describeChange(&self.plan, self.allocator, args.index, previous_status, args.note != null), .is_error = false };
}