v0.50.1 a637010

plan.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"strconv"
6	"strings"
7	"time"
8)
9
10// PlanOperation is one parsed plan tool call. The tool decodes the arguments;
11// core validates the operation against the current plan, materializes the
12// resulting list, and only then commits it. Fields that do not apply to the
13// selected action are ignored.
14type PlanOperation struct {
15	Action      string
16	Explanation *string
17	Steps       []PlanStep     // set, add
18	Index       int            // update, remove (1-based)
19	Text        *string        // update
20	Status      PlanStepStatus // update
21}
22
23// SessionPlan is the agent-owned checklist persisted in session metadata.
24// Steps is optional so older sessions remain compatible.
25type SessionPlan struct {
26	Steps []PlanStep `json:"steps,omitempty"`
27	// Updated is stamped by Session.UpdatePlan when the plan is persisted.
28	Updated time.Time `json:"updated"`
29}
30
31// SetPlan seeds the agent-owned plan. It copies the steps so the caller keeps
32// ownership of its slice. Hosts seed state on resume and on /clear, where a nil
33// agent is possible, so the guard is deliberate. Seeded steps are normalized, so
34// a host cannot install state that the read action cannot report.
35func (a *Agent) SetPlan(steps []PlanStep) {
36	if a == nil {
37		return
38	}
39	var seeded []PlanStep
40	if normalized := normalizeSessionPlan(&SessionPlan{Steps: steps}); normalized != nil {
41		seeded = clonePlanSteps(normalized.Steps)
42	}
43	a.mu.Lock()
44	defer a.mu.Unlock()
45	a.plan = seeded
46}
47
48// CurrentPlan returns a copy of the agent-owned plan. Mutating the result does
49// not affect agent state.
50func (a *Agent) CurrentPlan() []PlanStep {
51	if a == nil {
52		return nil
53	}
54	a.mu.Lock()
55	defer a.mu.Unlock()
56	return clonePlanSteps(a.plan)
57}
58
59// previewPlanOperation validates op against the current plan and returns the
60// prospective update without mutating agent state. Callers commit a validated
61// operation with commitPlanUpdate once persistence succeeds.
62func (a *Agent) previewPlanOperation(op PlanOperation) (PlanUpdate, error) {
63	a.mu.Lock()
64	defer a.mu.Unlock()
65	return a.previewPlanOperationLocked(op)
66}
67
68// commitPlanUpdate stores the plan that was just persisted. Callers pass the
69// value returned by previewPlanOperation after CommitToolResult succeeded, so
70// agent state always matches what the session file holds.
71//
72// Preview and commit are separate lock windows. When a concurrent SetPlan lands
73// between them, the persisted update wins: the session file now records that
74// list, so state must agree with the file rather than with the intervening call.
75func (a *Agent) commitPlanUpdate(update PlanUpdate) {
76	a.mu.Lock()
77	defer a.mu.Unlock()
78	a.plan = clonePlanSteps(update.Plan)
79}
80
81func (a *Agent) previewPlanOperationLocked(op PlanOperation) (PlanUpdate, error) {
82	switch op.Action {
83	case "set", "add", "update", "remove", "clear", "show":
84	case "":
85		return PlanUpdate{}, fmt.Errorf("plan: action is required")
86	default:
87		return PlanUpdate{}, fmt.Errorf("plan: unknown action %q", op.Action)
88	}
89
90	plan := clonePlanSteps(a.plan)
91	switch op.Action {
92	case "set":
93		// A replace must say what it replaces: an omitted or empty step list used
94		// to wipe the checklist silently, which is too destructive a default for a
95		// model that only meant to attach an explanation. Removing every step is an
96		// explicit intent, so it has its own action.
97		if len(op.Steps) == 0 {
98			return PlanUpdate{}, fmt.Errorf("plan: steps are required for set (use clear to remove every step)")
99		}
100		plan = defaultPlanStepStatuses(clonePlanSteps(op.Steps))
101	case "add":
102		if len(op.Steps) == 0 {
103			return PlanUpdate{}, fmt.Errorf("plan: steps are required for add")
104		}
105		plan = append(plan, defaultPlanStepStatuses(clonePlanSteps(op.Steps))...)
106	case "update":
107		if op.Index < 1 || op.Index > len(plan) {
108			return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
109		}
110		if op.Status == "" && op.Text == nil {
111			return PlanUpdate{}, fmt.Errorf("plan: update requires status or step")
112		}
113		step := plan[op.Index-1]
114		if op.Text != nil {
115			step.Step = *op.Text
116		}
117		if op.Status != "" {
118			step.Status = op.Status
119		}
120		plan[op.Index-1] = step
121	case "remove":
122		if op.Index < 1 || op.Index > len(plan) {
123			return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
124		}
125		plan = append(plan[:op.Index-1], plan[op.Index:]...)
126		if len(plan) == 0 {
127			plan = nil
128		}
129	case "clear":
130		plan = nil
131	case "show":
132		// show is a read: report the current plan without validating it. Seeding
133		// normalizes, but a read must not fail on state a host installed directly.
134		return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
135	}
136
137	if err := validatePlanSteps(plan); err != nil {
138		return PlanUpdate{}, err
139	}
140	return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
141}
142
143// validatePlanSteps rejects steps that cannot be rendered or persisted: empty
144// text or an unknown status. Several steps may be in_progress at once so
145// parallel work (for example concurrent sub-agents) can be tracked. There is
146// deliberately no length cap.
147func validatePlanSteps(plan []PlanStep) error {
148	for idx := range plan {
149		if strings.TrimSpace(plan[idx].Step) == "" {
150			return fmt.Errorf("plan: step text is required")
151		}
152		switch plan[idx].Status {
153		case PlanPending, PlanCompleted, PlanInProgress:
154		default:
155			return fmt.Errorf("plan: unknown status %q", plan[idx].Status)
156		}
157	}
158	return nil
159}
160
161// defaultPlanStepStatuses applies the schema's optional step status: a step that
162// omits it is pending. Without this, an omitted status would reach agent state
163// as an invalid value and later be dropped by normalizeSessionPlan on reload.
164func defaultPlanStepStatuses(steps []PlanStep) []PlanStep {
165	for idx := range steps {
166		if steps[idx].Status == "" {
167			steps[idx].Status = PlanPending
168		}
169	}
170	return steps
171}
172
173// planResultText renders the model-visible text for a validated plan
174// operation. Mutations return a compact summary that never echoes the list;
175// show returns the full checklist.
176func planResultText(action string, update PlanUpdate) string {
177	if action == "show" {
178		return planShowListing(update.Plan)
179	}
180	return planMutationSummary(update.Plan)
181}
182
183// planMutationSummary summarizes the resulting plan without repeating its steps:
184// the model already holds them in its own earlier messages.
185func planMutationSummary(plan []PlanStep) string {
186	if len(plan) == 0 {
187		return "Plan cleared"
188	}
189	completed := 0
190	var inProgress []string
191	for idx, step := range plan {
192		switch step.Status {
193		case PlanCompleted:
194			completed++
195		case PlanInProgress:
196			inProgress = append(inProgress, strconv.Itoa(idx+1))
197		}
198	}
199	summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan))
200	switch len(inProgress) {
201	case 0:
202	case 1:
203		summary += "; in progress: step " + inProgress[0]
204	default:
205		summary += "; in progress: steps " + strings.Join(inProgress, ", ")
206	}
207	return summary + ")"
208}
209
210// planShowListing renders the full checklist for the show action.
211func planShowListing(plan []PlanStep) string {
212	if len(plan) == 0 {
213		return "Plan is empty"
214	}
215	var b strings.Builder
216	for idx, step := range plan {
217		if idx > 0 {
218			b.WriteByte('\n')
219		}
220		fmt.Fprintf(&b, "%d. [%s] %s", idx+1, planStepMarker(step.Status), step.Step)
221	}
222	return b.String()
223}
224
225// planStepMarker is the one-character checkbox shown for a step status.
226func planStepMarker(status PlanStepStatus) string {
227	switch status {
228	case PlanCompleted:
229		return "x"
230	case PlanInProgress:
231		return "~"
232	default:
233		return " "
234	}
235}
236
237// clonePlanSteps returns an independent copy of steps, or nil when empty.
238func clonePlanSteps(steps []PlanStep) []PlanStep {
239	if len(steps) == 0 {
240		return nil
241	}
242	clone := make([]PlanStep, len(steps))
243	copy(clone, steps)
244	return clone
245}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close