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}