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